fix(vision): SSRF 三层守卫 + base64 长度预检(纵深防御,F3/G3 的收尾)
- SSRF 三层:① 字面主机判定(**链路本地** `169.254.0.0/16`/`fe80::/10`(先 Unmap ⇒ `::ffff:169.254.169.254` 绕不过)+ 云元数据 `100.100.100.200`、`fd00:ec2::/64`);② 重定向逐跳校验 + 上限 5 跳; ③ **建连前校验解析结果并用校验过的 IP 直连**(防 DNS 重绑,拨号器不再二次解析) - **没打断同源自取**(两条独立证据):`127.0.0.1:<port>/api/v1/files/<32hex>.png` 端到端取回成功、 私网地址**原样交给底层拨号器**;被拒地址则底层拨号器 0 次调用(无真实连接发出) - 失败文案**不含 URL/查询串签名**;非图片响应仍 `ErrNotImage` 且不泄漏响应体 - base64 路径长度预检(**先于 DecodeString 与 `data:` 短路**,零分配):口径与网关同值同推导 (`((5MiB+2)/3)*4+64`),超限 `ErrImageTooLarge`、文案只含上限与本次长度 - 复核:vision 包 build/vet/测试绿(15 条新用例);**4 条变异反证**(守卫整体短路 → 8 条被拒地址全中; 只短路链路本地分支 → 4 红且实测「无守卫时进程真的去连 169.254.169.254」;预检恒 nil → 3 红; 去掉重定向校验仍全绿 ⇒ 证明第三层独立兜住) - 登记:`CHAT_IMAGE_MAX_BYTES` 调大 > 默认时「网关照放、vision 拒」的配置耦合(待单一真源对齐)
This commit is contained in:
1 parent
c96a7db7f1
commit
e64ea0b156
6 files changed
+686
-8
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,7 +22,8 @@ const (
|
||||
|
||||
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
|
||||
// 为模型代取 URL,没有上限时一个卡住或恶意的地址会一直占着这一轮
|
||||
// (fetchURL 复用默认 client,其本身不设整体超时)。
|
||||
// (fetchURL 经 guardedImageClient 给未设超时的 client 补上它,
|
||||
// 拨号守卫也用同一个值作单次建连上限)。
|
||||
const imageFetchTimeout = 30 * time.Second
|
||||
|
||||
// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。
|
||||
@@ -66,7 +68,12 @@ func readFile(path string) (string, []byte, error) {
|
||||
}
|
||||
|
||||
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
|
||||
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
|
||||
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。
|
||||
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)
|
||||
@@ -83,22 +90,35 @@ func decodeBase64(src string) (string, []byte, error) {
|
||||
}
|
||||
|
||||
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
|
||||
// 取回前、重定向每一跳与真正建连前都有守卫(见 guard.go):链路本地与
|
||||
// 云元数据地址被拒,环回/私网/公网照旧可达(同源自取需要它们)。
|
||||
// 声明的 Content-Type 只作参考,真实字节嗅探说了算(见 imageMIMEFor):
|
||||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||||
}
|
||||
if client == nil {
|
||||
client = defaultHTTPClient()
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// 不回显 URL:解析失败的原文里带着整条地址(可能含凭据)。
|
||||
return "", nil, fmt.Errorf("vision: %w: URL 无法解析", ErrFetch)
|
||||
}
|
||||
if err := guardImageURL(u); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
guarded := guardedImageClient(client)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||||
return "", nil, fmt.Errorf("vision: %w: 无法构造请求", ErrFetch)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := guarded.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||||
// 被拒地址直接返回类型化错误:它只含主机与网段原因,
|
||||
// 不像 client.Do 的 *url.Error 那样把整条 URL(可能含凭据)带出来。
|
||||
if blocked, ok := blockedAddressOf(err); ok {
|
||||
return "", nil, blocked
|
||||
}
|
||||
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, fetchCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -117,10 +137,14 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string,
|
||||
|
||||
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
|
||||
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
|
||||
// 逐字节不变)。URL 取回复用 Resolve→fetchURL 的既有守卫
|
||||
// (协议白名单 + 体积上限),并加取回超时。
|
||||
// 逐字节不变),但**短路之前**也要过长度预检:这条路径不解码,不预检
|
||||
// 就等于把体积上限完全交给入口层。URL 取回复用 Resolve→fetchURL 的既有
|
||||
// 守卫(协议白名单 + 体积上限 + 地址守卫),并加取回超时。
|
||||
func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
if err := checkInlineImageLength(src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
ref, err := ParseImageRef(src)
|
||||
|
||||
Reference in new issue
Block a user