- 原缺陷: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 可达)→ 需收紧
178 lines
7.5 KiB
Go
178 lines
7.5 KiB
Go
package vision
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
)
|
|
|
|
// uploadTestName 是一个形状合法的上传文件名(32 位小写十六进制 + .png)。
|
|
const uploadTestName = "0123456789abcdef0123456789abcdef.png"
|
|
|
|
// TestLocalUploadPath 是上传 URL → 本地路径的路径穿越防护用例表:
|
|
// 只有严格形状才被接受,任何分隔符/点号段/编码变体都在触碰文件系统前被拒。
|
|
func TestLocalUploadPath(t *testing.T) {
|
|
t.Parallel()
|
|
dir := filepath.Join("tmp", "uploads")
|
|
tests := []struct {
|
|
name string
|
|
rawURL string
|
|
dir string
|
|
want string
|
|
wantHit bool
|
|
}{
|
|
{name: "http upload url", rawURL: "http://192.168.1.5:10942/api/v1/files/" + uploadTestName,
|
|
dir: dir, want: filepath.Join(dir, uploadTestName), wantHit: true},
|
|
{name: "https upload url", rawURL: "https://box.local/api/v1/files/" + uploadTestName,
|
|
dir: dir, want: filepath.Join(dir, uploadTestName), wantHit: true},
|
|
{name: "traversal", rawURL: "http://h/api/v1/files/../../../data/cyrene.db", dir: dir, wantHit: false},
|
|
{name: "encoded traversal", rawURL: "http://h/api/v1/files/%2e%2e%2fcyrene.db", dir: dir, wantHit: false},
|
|
{name: "backslash", rawURL: `http://h/api/v1/files/..\cyrene.db`, dir: dir, wantHit: false},
|
|
{name: "absolute path name", rawURL: "http://h/api/v1/files//etc/passwd", dir: dir, wantHit: false},
|
|
{name: "uppercase hex", rawURL: "http://h/api/v1/files/" + strings.ToUpper(uploadTestName), dir: dir, wantHit: false},
|
|
{name: "short stem", rawURL: "http://h/api/v1/files/abcdef.png", dir: dir, wantHit: false},
|
|
{name: "non hex stem", rawURL: "http://h/api/v1/files/0123456789abcdef0123456789abcdeg.png", dir: dir, wantHit: false},
|
|
{name: "html extension", rawURL: "http://h/api/v1/files/" + strings.TrimSuffix(uploadTestName, ".png") + ".html", dir: dir, wantHit: false},
|
|
{name: "svg extension", rawURL: "http://h/api/v1/files/" + strings.TrimSuffix(uploadTestName, ".png") + ".svg", dir: dir, wantHit: false},
|
|
{name: "extra segment", rawURL: "http://h/api/v1/files/sub/" + uploadTestName, dir: dir, wantHit: false},
|
|
{name: "query string", rawURL: "http://h/api/v1/files/" + uploadTestName + "?x=1", dir: dir, wantHit: false},
|
|
{name: "fragment", rawURL: "http://h/api/v1/files/" + uploadTestName + "#f", dir: dir, wantHit: false},
|
|
{name: "other route", rawURL: "http://h/api/v1/other/" + uploadTestName, dir: dir, wantHit: false},
|
|
{name: "ftp scheme", rawURL: "ftp://h/api/v1/files/" + uploadTestName, dir: dir, wantHit: false},
|
|
{name: "relative url", rawURL: "/api/v1/files/" + uploadTestName, dir: dir, wantHit: false},
|
|
{name: "remote without uploads dir", rawURL: "http://h/api/v1/files/" + uploadTestName, dir: "", wantHit: false},
|
|
{name: "empty name", rawURL: "http://h/api/v1/files/", dir: dir, wantHit: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, ok := localUploadPath(tt.rawURL, tt.dir)
|
|
if ok != tt.wantHit {
|
|
t.Fatalf("localUploadPath(%q, %q) = (%q, %v), want hit=%v", tt.rawURL, tt.dir, got, ok, tt.wantHit)
|
|
}
|
|
if tt.wantHit && got != tt.want {
|
|
t.Fatalf("path = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenAICompatibleAdapter_ReadsOwnUploadLocally 覆盖同源 upload 产物的
|
|
// 快路径:URL 指向本服务的 /api/v1/files/<name> 时直接读盘,不发起网络
|
|
// 请求(URL 的 host 在此不可达,若走了 HTTP 必然失败/超时)。
|
|
func TestOpenAICompatibleAdapter_ReadsOwnUploadLocally(t *testing.T) {
|
|
t.Parallel()
|
|
png := pngBytes(t)
|
|
dataDir := t.TempDir()
|
|
uploads := filepath.Join(dataDir, "uploads")
|
|
if err := writeFileBytesAll(uploads, uploadTestName, png); err != nil {
|
|
t.Fatalf("seed upload: %v", err)
|
|
}
|
|
upSrv, rec := newImageURLRecorderServer(t)
|
|
ad := newAdapter(upSrv.URL, WithUploadsDir(uploads))
|
|
|
|
// 127.0.0.1:1 上是没有服务的:一旦退回网络取回就会立刻失败。
|
|
srcURL := "http://127.0.0.1:1/api/v1/files/" + uploadTestName
|
|
if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, ""); err != nil {
|
|
t.Fatalf("Describe via the local upload path: %v", err)
|
|
}
|
|
_, urls := rec.seen()
|
|
if len(urls) != 1 || urls[0] != DataURI("image/png", png) {
|
|
t.Fatalf("image_url = %q, want the local file inlined", urls)
|
|
}
|
|
}
|
|
|
|
// TestOpenAICompatibleAdapter_MissingUploadFailsLoudly 钉住「认得的上传 URL
|
|
// 但文件不在」不会偷偷退回网络:错误如实上报(不静默换成别的内容)。
|
|
func TestOpenAICompatibleAdapter_MissingUploadFailsLoudly(t *testing.T) {
|
|
t.Parallel()
|
|
uploads := filepath.Join(t.TempDir(), "uploads")
|
|
upSrv, rec := newImageURLRecorderServer(t)
|
|
ad := newAdapter(upSrv.URL, WithUploadsDir(uploads))
|
|
|
|
srcURL := "http://127.0.0.1:1/api/v1/files/" + uploadTestName
|
|
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, "")
|
|
if err == nil {
|
|
t.Fatal("Describe succeeded without the upload on disk, want an error")
|
|
}
|
|
if !errors.Is(err, ErrBadImage) {
|
|
t.Fatalf("error = %v, want ErrBadImage", err)
|
|
}
|
|
if calls, _ := rec.seen(); calls != 0 {
|
|
t.Fatalf("upstream was called %d times, want 0", calls)
|
|
}
|
|
}
|
|
|
|
// TestOpenAICompatibleAdapter_TraversalNotReadLocally 是越界文件名的反证
|
|
// 用例:本地 secret.png 与远端返回的图内容不同,`../` 名称必须落到远端
|
|
// 取回(而不是被读成本地越界文件)。
|
|
func TestOpenAICompatibleAdapter_TraversalNotReadLocally(t *testing.T) {
|
|
t.Parallel()
|
|
local := pngBytes(t)
|
|
remote := append([]byte{}, pngBytes(t)...)
|
|
remote = append(remote, 0x00) // 内容不同即可,仍是合法 PNG 前缀
|
|
var hits atomic.Int32
|
|
|
|
dataDir := t.TempDir()
|
|
uploads := filepath.Join(dataDir, "uploads")
|
|
if err := writeFileBytesAll(uploads, uploadTestName, local); err != nil {
|
|
t.Fatalf("seed uploads dir: %v", err)
|
|
}
|
|
if err := writeFileBytes(filepath.Join(dataDir, "secret.png"), local); err != nil {
|
|
t.Fatalf("seed secret: %v", err)
|
|
}
|
|
|
|
imgSrv := newImageServer(t, remote, "image/png", &hits)
|
|
upSrv, rec := newImageURLRecorderServer(t)
|
|
ad := newAdapter(upSrv.URL, WithUploadsDir(uploads))
|
|
|
|
srcURL := imgSrv.URL + "/api/v1/files/../secret.png"
|
|
if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, ""); err != nil {
|
|
t.Fatalf("Describe: %v", err)
|
|
}
|
|
_, urls := rec.seen()
|
|
if len(urls) != 1 {
|
|
t.Fatalf("image_url = %v, want one part", urls)
|
|
}
|
|
if urls[0] == DataURI("image/png", local) {
|
|
t.Fatal("the ../ name was resolved to a local file outside uploads")
|
|
}
|
|
if urls[0] != DataURI("image/png", remote) {
|
|
t.Fatalf("image_url = %q, want the remote payload", urls[0])
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Fatalf("source fetched %d times, want 1", hits.Load())
|
|
}
|
|
}
|
|
|
|
// TestWithUploadsDirIsOptional 钉住未配置上传目录时行为不变:上传形状的
|
|
// URL 仍按普通 URL 取回。
|
|
func TestWithUploadsDirIsOptional(t *testing.T) {
|
|
t.Parallel()
|
|
png := pngBytes(t)
|
|
var hits atomic.Int32
|
|
imgSrv := newImageServer(t, png, "image/png", &hits)
|
|
upSrv, rec := newImageURLRecorderServer(t)
|
|
ad := NewOpenAICompatible(config.LLMConfig{BaseURL: upSrv.URL, Model: "vision-test"})
|
|
|
|
_, err := ad.Describe(context.Background(), ImageRef{
|
|
Source: SourceURL,
|
|
Data: imgSrv.URL + "/api/v1/files/" + uploadTestName,
|
|
}, "")
|
|
if err != nil {
|
|
t.Fatalf("Describe: %v", err)
|
|
}
|
|
_, urls := rec.seen()
|
|
if len(urls) != 1 || urls[0] != DataURI("image/png", png) {
|
|
t.Fatalf("image_url = %q, want the fetched payload", urls)
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Fatalf("source fetched %d times, want 1", hits.Load())
|
|
}
|
|
}
|