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
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 读取图像字节与响应体的体积上限,使恶意或
|
||||
@@ -18,6 +19,11 @@ const (
|
||||
maxFetchedBody = 50 << 20
|
||||
)
|
||||
|
||||
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
|
||||
// 为模型代取 URL,没有上限时一个卡住或恶意的地址会一直占着这一轮
|
||||
// (fetchURL 复用默认 client,其本身不设整体超时)。
|
||||
const imageFetchTimeout = 30 * time.Second
|
||||
|
||||
// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。
|
||||
func DataURI(mime string, data []byte) string {
|
||||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
|
||||
@@ -40,7 +46,8 @@ func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime strin
|
||||
}
|
||||
}
|
||||
|
||||
// readFile 读取一个图像文件,并从扩展名推断其 MIME 类型。
|
||||
// readFile 读取一个图像文件。扩展名给出声明的 MIME,真实字节嗅探是否决权
|
||||
// (见 imageMIMEFor):改名的 HTML 不会被当成图片读进模型。
|
||||
func readFile(path string) (string, []byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -51,7 +58,11 @@ func readFile(path string) (string, []byte, error) {
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||||
}
|
||||
return mimeByExt(path), data, nil
|
||||
mime, err := imageMIMEFor(mimeByExt(path), data)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return mime, data, nil
|
||||
}
|
||||
|
||||
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
|
||||
@@ -72,6 +83,8 @@ func decodeBase64(src string) (string, []byte, error) {
|
||||
}
|
||||
|
||||
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
|
||||
// 声明的 Content-Type 只作参考,真实字节嗅探说了算(见 imageMIMEFor):
|
||||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||||
@@ -95,7 +108,35 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string,
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||||
}
|
||||
return mimeByContentType(resp.Header.Get("Content-Type")), data, nil
|
||||
mime, err := imageMIMEFor(resp.Header.Get("Content-Type"), data)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return mime, data, nil
|
||||
}
|
||||
|
||||
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
|
||||
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
|
||||
// 逐字节不变)。URL 取回复用 Resolve→fetchURL 的既有守卫
|
||||
// (协议白名单 + 体积上限),并加取回超时。
|
||||
func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
return src, nil
|
||||
}
|
||||
ref, err := ParseImageRef(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ref.Source == SourceURL {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, imageFetchTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
mime, data, err := Resolve(ctx, ref, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return DataURI(mime, data), nil
|
||||
}
|
||||
|
||||
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是
|
||||
@@ -124,46 +165,6 @@ func splitDataURI(src string) (mime, payload string, ok bool) {
|
||||
return mime, payload, true
|
||||
}
|
||||
|
||||
// mimeByExt 从文件路径扩展名推断 MIME 类型。
|
||||
func mimeByExt(path string) string {
|
||||
switch strings.ToLower(pathExtension(path)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
// mimeByContentType 从 HTTP Content-Type 头返回 MIME 类型,
|
||||
// 缺失或非图像时默认为 image/png。
|
||||
func mimeByContentType(ct string) string {
|
||||
if ct == "" || !strings.HasPrefix(ct, "image/") {
|
||||
return "image/png"
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(ct, ";")[0])
|
||||
}
|
||||
|
||||
// pathExtension 返回带点号的文件扩展名,没有则返回空串。
|
||||
func pathExtension(path string) string {
|
||||
base := path
|
||||
if idx := strings.LastIndexByte(path, '/'); idx >= 0 {
|
||||
base = path[idx+1:]
|
||||
} else if idx := strings.LastIndexByte(path, '\\'); idx >= 0 {
|
||||
base = path[idx+1:]
|
||||
}
|
||||
dot := strings.LastIndexByte(base, '.')
|
||||
if dot < 0 {
|
||||
return ""
|
||||
}
|
||||
return base[dot:]
|
||||
}
|
||||
|
||||
// fileExists 报告 path 是否存在于磁盘且为常规文件。
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
Reference in new issue
Block a user