- 原缺陷: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 可达)→ 需收紧
149 lines
4.8 KiB
Go
149 lines
4.8 KiB
Go
package vision
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
// sniffBytes 是字节嗅探读取的前缀长度:http.DetectContentType 只看前
|
||
// 512 字节。
|
||
const sniffBytes = 512
|
||
|
||
// knownRasterMIME 把一个 MIME 归一化到允许内联进视觉模型的光栅图
|
||
// 白名单。白名单与网关上传同口径(internal/services/gateway/upload.go
|
||
// 的 uploadExtForMIME):SVG/HTML 是可执行内容,绝不进模型。
|
||
// 第二个返回值为 false 时该 MIME 不是白名单内的图像类型。
|
||
func knownRasterMIME(mime string) (string, bool) {
|
||
switch mediaType(mime) {
|
||
case "image/png":
|
||
return "image/png", true
|
||
case "image/jpeg", "image/jpg":
|
||
// image/jpg 不是标准写法,但确有其事,归一化到 image/jpeg。
|
||
return "image/jpeg", true
|
||
case "image/gif":
|
||
return "image/gif", true
|
||
case "image/webp":
|
||
return "image/webp", true
|
||
case "image/bmp":
|
||
return "image/bmp", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
// imageMediaType 返回 Content-Type 里声明的 image/* 媒体类型(去掉参数、
|
||
// 转小写)。缺失、非 image/*、或载体可执行的 image/svg+xml 都返回 false。
|
||
func imageMediaType(ct string) (string, bool) {
|
||
m := mediaType(ct)
|
||
if !strings.HasPrefix(m, "image/") || m == "image/svg+xml" {
|
||
return "", false
|
||
}
|
||
return m, true
|
||
}
|
||
|
||
// mediaType 归一化一个 MIME 字符串:截掉 ";charset=..." 参数、去空白、转小写。
|
||
func mediaType(mime string) string {
|
||
return strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0]))
|
||
}
|
||
|
||
// sniffMIME 按真实字节嗅探类型。它是对声明类型(HTTP Content-Type 或文件
|
||
// 扩展名)的否决权:两者都可能骗人,字节不会。
|
||
func sniffMIME(data []byte) string {
|
||
if len(data) > sniffBytes {
|
||
data = data[:sniffBytes]
|
||
}
|
||
return http.DetectContentType(data)
|
||
}
|
||
|
||
// isTextualMIME 报告 mime 是否是可读文本 —— HTML/PROXY 错误页、SVG、
|
||
// JSON 等「看起来像图但其实是文本」的载荷都落在这里。
|
||
func isTextualMIME(mime string) bool {
|
||
m := mediaType(mime)
|
||
if strings.HasPrefix(m, "text/") {
|
||
return true
|
||
}
|
||
switch m {
|
||
case "application/json", "application/xml", "application/xhtml+xml", "application/javascript":
|
||
return true
|
||
default:
|
||
return strings.HasSuffix(m, "+xml")
|
||
}
|
||
}
|
||
|
||
// imageMIMEFor 决定内联给视觉模型的 MIME。declared 是来源声明的类型
|
||
// (HTTP Content-Type 或文件扩展名),data 是真实字节:
|
||
//
|
||
// - 字节能判出白名单光栅图 → 以字节为准(声明的类型只作参考,
|
||
// 谎报或错标的类型骗不了模型);
|
||
// - 字节是文本(HTML/PROXY 错误页、SVG)→ 报错,绝不喂给模型;
|
||
// - 字节判不定(application/octet-stream 等)且来源明确声明 image/* →
|
||
// 采信声明(否则 avif 这类嗅探不出的格式会被无谓拒绝);
|
||
// - 其余(含类型缺失且字节判不定)→ 报错。
|
||
//
|
||
// 绝不静默回退成 image/png:把一张 HTML 错误页当 PNG 交给视觉模型,
|
||
// 会让模型对着乱码编描述。
|
||
func imageMIMEFor(declared string, data []byte) (string, error) {
|
||
sniffed := sniffMIME(data)
|
||
if sniffedMIME, ok := knownRasterMIME(sniffed); ok {
|
||
return sniffedMIME, nil
|
||
}
|
||
declaredMIME, declaredImage := imageMediaType(declared)
|
||
if isTextualMIME(sniffed) {
|
||
return "", fmt.Errorf("vision: %w: declared %s but the payload is %s",
|
||
ErrNotImage, declaredLabel(declared), mediaType(sniffed))
|
||
}
|
||
if declaredImage {
|
||
if known, ok := knownRasterMIME(declaredMIME); ok {
|
||
return known, nil
|
||
}
|
||
return declaredMIME, nil
|
||
}
|
||
return "", fmt.Errorf("vision: %w: declared %s, payload is %s",
|
||
ErrNotImage, declaredLabel(declared), mediaType(sniffed))
|
||
}
|
||
|
||
// declaredLabel 把声明的类型渲染成错误信息里的可读标签;缺失时给出
|
||
// 明确措辞,避免错误信息里出现空的引号。错误信息只含类型名,
|
||
// 绝不含图像字节或来源 URL(后者可能带凭据)。
|
||
func declaredLabel(declared string) string {
|
||
if m := mediaType(declared); m != "" {
|
||
return fmt.Sprintf("content type %q", m)
|
||
}
|
||
return "no content type"
|
||
}
|
||
|
||
// mimeByExt 给出文件路径扩展名声明的 MIME;未知扩展名返回 "",交给字节
|
||
// 嗅探裁决(不再静默假定 png)。
|
||
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"
|
||
case ".bmp":
|
||
return "image/bmp"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 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:]
|
||
}
|