fix(vision): 服务端自己取回图片 URL 并内联为 data URI,修自部署下取不到图(F3,优先级「高」)
- 原缺陷:vision 适配器把 http(s) URL **原样转给上游 provider**,而 upload 返回的是同源局域网地址 ⇒ 云 provider 根本取不到 ⇒ **B1(大图绕开 WS 4096 字节上限)在自部署形态下等于没做** - 修法:① `imageURL` → `localizeRef`:**彻底不再直传 URL**;同源 upload 产物走**本地读取**, 其余 http(s) 由服务端自己取回 → 一律内联为 `data:` URI 再交 provider(OpenAI 与 Anthropic 双协议都能收图) - 顺手修「类型瞎猜」:新增 `vision/mime.go`,**字节嗅探是唯一裁决**——判得出白名单光栅图就用字节结果; 字节是文本(HTML/PROXY 错误页、SVG)→ `ErrNotImage`;判不定但来源明确声明 `image/*` 才采信声明。 错误信息只含类型名,**不含响应体/URL**(不打印图片内容) - 防护:取回复用既有 http/https 白名单 + 50MiB 上限,新增 **30s 取回超时**; 本地读取路径 `localUploadPath` 严格认定 `<host>/api/v1/files/<name>`(无 query/fragment、单段路径)+ 32 位小写十六进制名 + `filepath.Dir` 包含性第二道 ⇒ `../`、`%2e%2e%2f`、反斜杠等编码变体在触碰 FS 前即拒 - 生产接线(主会话补):`cmd/cyrene/vision.go` 传 `vision.WithUploadsDir(<DataDir>/uploads)` - 复核:build/vet 绿;新增 51 条断言(含 httptest 真取回 → 断言上游收到 data URI 且不含源 URL、 同源 upload 用不可达 host 证明走本地、HTML 谎报 image/png 被字节否决、越界名 19 例); **3 条变异反证**(恢复 URL 直传 8 红 / 文本静默回退 png 红 / 去掉穿越检查 12 红)均已还原 - 未做(已登记):历史里 URL 形态图片每轮重新取回(缓解=持久化时内联/缓存); Anthropic 只认 png/jpeg/gif/webp 而白名单含 bmp;`fetchURL` 无 IP 过滤(链路本地 169.254.169.254 可达)→ 需收紧
This commit is contained in:
1 parent
88d7dd0026
commit
983bc49218
11 files changed
+971
-55
No files matched your search
@@ -0,0 +1,107 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// writeFileBytes 写一个测试用的字节文件。
|
||||
func writeFileBytes(path string, data []byte) error {
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write test file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileBytesAll 在 dir 下写 name(含父目录),用于摆放测试用的上传产物。
|
||||
func writeFileBytesAll(dir, name string, data []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create test dir: %w", err)
|
||||
}
|
||||
return writeFileBytes(filepath.Join(dir, name), data)
|
||||
}
|
||||
|
||||
// imageURLRecorder 是一个 /chat/completions endpoint,记录发往上游的
|
||||
// image_url 取值,使测试能断言「服务端真的把图取回并内联了」而不是
|
||||
// 原样透传 URL。
|
||||
type imageURLRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
urls []string
|
||||
}
|
||||
|
||||
func (r *imageURLRecorder) seen() (int, []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.calls, append([]string(nil), r.urls...)
|
||||
}
|
||||
|
||||
// newImageURLRecorderServer 起一个上游 endpoint,把每次请求里的
|
||||
// image_url.url 记下来。
|
||||
func newImageURLRecorderServer(t *testing.T) (*httptest.Server, *imageURLRecorder) {
|
||||
t.Helper()
|
||||
rec := &imageURLRecorder{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []struct {
|
||||
Content []struct {
|
||||
ImageURL *struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image_url"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode upstream body: %v", err)
|
||||
}
|
||||
rec.mu.Lock()
|
||||
rec.calls++
|
||||
for _, m := range body.Messages {
|
||||
for _, part := range m.Content {
|
||||
if part.ImageURL != nil {
|
||||
rec.urls = append(rec.urls, part.ImageURL.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"a small square"}}]}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
// newImageServer 起一个提供图像字节的来源服务,contentType 为 "" 时
|
||||
// 刻意不发 Content-Type(考验字节嗅探)。
|
||||
func newImageServer(t *testing.T, data []byte, contentType string, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hits != nil {
|
||||
hits.Add(1)
|
||||
}
|
||||
if contentType == "" {
|
||||
w.Header()["Content-Type"] = nil
|
||||
w.Header().Set("Content-Type", "")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newAdapter 构造一个指向 up 的适配器,opts 追加在必需项之后。
|
||||
func newAdapter(up string, opts ...OpenAIOption) *OpenAICompatibleAdapter {
|
||||
return NewOpenAICompatible(config.LLMConfig{BaseURL: up, Model: "vision-test"}, opts...)
|
||||
}
|
||||
Reference in new issue
Block a user