Files
Cyrene_v2/internal/runtime/multimodal/vision/upload.go
T
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

90 lines
3.2 KiB
Go
Raw 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 (
"net/url"
"path/filepath"
"strings"
)
// uploadFilePrefix 是本服务提供已存上传的静态路由前缀,与网关
// internal/services/gateway/upload.go 的 uploadFilePrefix 一致。
// 它位于 /api/v1/ 之下,因此属于版本化客户端契约。
const uploadFilePrefix = "/api/v1/files/"
// uploadNameBytes 是上传文件主名的字节数(16 字节 = 32 位十六进制),
// 与网关 randomUploadName 一致。
const uploadNameBytes = 16
// localUploadPath 判断 rawURL 是否指向本服务的已存上传,若是则返回它在
// uploadsDir 下的本地路径。命中时直接读盘:比绕一圈 HTTP 回自己更快,
// 也不需要网络可达(自部署时同源 URL 用的是局域网地址,服务端自己
// 未必路由得到,云 provider 则必然取不到)。
//
// 认可的形状严格为 <http|https>://<host>/api/v1/files/<name>,且 <name>
// 必须通过 validUploadName。查询串、片段、多余路径段、任何分隔符或
// 点号段都在触碰文件系统之前被拒绝;最后的包含性检查是
// 第二道防线,即使名字校验被改弱,路径也不得逃出 uploadsDir。
//
// 注意 host 不参与判断:按网关设计,128 位随机文件名本身就是能力
// (静态上传路由刻意不认证),因此指向别处的同名 URL 也只解析到
// 本机同名上传,不会替调用方去取第三方内容。
func localUploadPath(rawURL, uploadsDir string) (string, bool) {
if strings.TrimSpace(uploadsDir) == "" {
return "", false
}
u, err := url.Parse(rawURL)
if err != nil {
return "", false
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", false
}
if u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
return "", false
}
name, ok := strings.CutPrefix(u.Path, uploadFilePrefix)
if !ok || !validUploadName(name) {
return "", false
}
path := filepath.Join(uploadsDir, name)
if filepath.Dir(path) != filepath.Clean(uploadsDir) {
return "", false
}
return path, true
}
// validUploadName 报告 name 是否恰好是 32 位小写十六进制主名加上白名单
// 内的图片扩展名。它与网关静态路由的路径穿越防护同口径
// (upload.go 的 validUploadName):分隔符、点、双点及任何其他形态都在
// 触碰文件系统之前被拒绝。
//
// 这里不复用网关那份实现是为了不让本包依赖 HTTP 服务层
// (runtime → services 是反向依赖);两处规则必须一起改。
func validUploadName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
return false
}
stem, ext, ok := strings.Cut(name, ".")
if !ok || len(stem) != uploadNameBytes*2 || ext == "" {
return false
}
for _, c := range stem {
if !isLowerHex(c) {
return false
}
}
return knownUploadExt(ext)
}
// knownUploadExt 报告 ext(不含点)是否是白名单内的图片扩展名,与网关
// uploadExtForMIME 的可写侧一致:只含光栅图。
func knownUploadExt(ext string) bool {
_, ok := knownRasterMIME(mimeByExt("upload." + strings.ToLower(ext)))
return ok
}
// isLowerHex 报告 c 是否是 ASCII 小写十六进制数字。
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}