- 原缺陷: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 可达)→ 需收紧
90 lines
3.2 KiB
Go
90 lines
3.2 KiB
Go
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')
|
||
}
|