Files
AskaEth 983bc49218
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 可达)→ 需收紧
2026-10-04 14:19:28 +08:00

149 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:]
}