fix(multimodal): 视觉工具的空/畸形 data: URI 明确拒绝 + 入参嗅探与可行动报错

根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
  image-crop → 零字节进 image.Decode ⇒ image: unknown format
  vision     → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)

- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
  且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
This commit is contained in:
AskaEth committed 2026-10-07 18:50:45 +08:00
1 parent da1d23dd23
commit 789e3af74e
8 files changed
+636 -24

No files matched your search

+37 -18
View File
@@ -34,7 +34,8 @@ func DataURI(mime string, data []byte) string {
// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在
// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时
// 用默认 client)。对 base64 输入,Data 字段原样作为
// 载荷返回,MIME 类型从 data: 前缀推断。
// 载荷返回,MIME 类型从 data: 前缀推断;data: 载荷解码后还要过
// 内容嗅探(见 checkInlinePayload),不是图片一律否决。
func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) {
switch ref.Source {
case SourcePath:
@@ -70,23 +71,38 @@ func readFile(path string) (string, []byte, error) {
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。
//
// data: URI 解码后要过内容嗅探(checkInlinePayload):前缀里的声明与真实字节
// 对不上时否决,绝不把「挂着 image/* 声明、内容却不是图片」的载荷转成 data: URI
// 交给上游(2026-10-07 的实测缺陷:`data:image/png;base64,` 只带前缀不带载荷,
// 旧代码照样报 image/png,空载荷被当成图片发给上游)。
//
// 裸 base64 路径保持原状:它没有声明可对照,包内契约定的是「按 image/png 处理」
// (见 Resolve 的文档),加嗅探就是改这条契约。
func decodeBase64(src string) (string, []byte, error) {
if err := checkInlineImageLength(src); err != nil {
return "", nil, err
}
mime, encoded, isDataURI := splitDataURI(src)
if isDataURI {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if !isDataURI {
// 以 data: 开头却没解析出 (mime, payload) 只有一个成因:**畸形**的
// data: URI(缺逗号分隔符,或没有 ;base64 段 —— 本包只处理 base64 载荷,
// 与 image-crop 同口径)。放它走下面的裸 base64 解码,报出来的是
// base64 语法错误,指不到真正的毛病,故在此明确拒绝。
if strings.HasPrefix(src, "data:") {
return "", nil, fmt.Errorf("vision: %w: malformed data: URI (want data:<mime>;base64,<base64 payload>)", ErrBadImage)
}
decoded, err := base64.StdEncoding.DecodeString(src)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return mime, decoded, nil
return "image/png", decoded, nil
}
decoded, err := base64.StdEncoding.DecodeString(src)
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return "image/png", decoded, nil
return checkInlinePayload(mime, decoded)
}
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
@@ -184,8 +200,14 @@ func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
}
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是
// data: URI 时第二个返回值是 false。
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是 data: URI、
// 或虽是 data: URI 但不带 ;base64 段时返回 false。
//
// 只认 base64 载荷(与 image-crop 的 decodeCropDataURI 同口径):百分号编码的
// data: URI 从来就不是本包的输入,把它当 base64 解码只会得到一段垃圾字节,
// 还挂着 image/png 的假声明送进模型 —— 这类串现在按畸形 data: URI 拒绝。
// 返回的 MIME 按包内统一口径归一化(去参数、转小写,见 mediaType);声明缺失
// (`data:;base64,…`)时给 image/png,与本函数历来的默认一致。
func splitDataURI(src string) (mime, payload string, ok bool) {
if !strings.HasPrefix(src, "data:") {
return "", "", false
@@ -196,18 +218,15 @@ func splitDataURI(src string) (mime, payload string, ok bool) {
return "", "", false
}
meta := rest[:comma]
payload = rest[comma+1:]
mime = "image/png"
if strings.HasPrefix(meta, ";base64") {
return mime, payload, true
idx := strings.Index(meta, ";base64")
if idx < 0 {
return "", "", false
}
if idx := strings.Index(meta, ";base64"); idx >= 0 {
if t := strings.TrimSuffix(meta[:idx], ";base64"); t != "" {
mime = t
}
return mime, payload, true
mime = mediaType(meta[:idx])
if mime == "" {
mime = "image/png"
}
return mime, payload, true
return mime, rest[comma+1:], true
}
// fileExists 报告 path 是否存在于磁盘且为常规文件。