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:
1 parent
da1d23dd23
commit
789e3af74e
8 files changed
+636
-24
No files matched your search
@@ -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 是否存在于磁盘且为常规文件。
|
||||
|
||||
Reference in new issue
Block a user