fix(vision): SSRF 三层守卫 + base64 长度预检(纵深防御,F3/G3 的收尾)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 16:24:56 +08:00
1 parent c96a7db7f1
commit e64ea0b156
6 files changed
+686 -8

No files matched your search

@@ -0,0 +1,128 @@
package vision
import (
"bytes"
"context"
"encoding/base64"
"errors"
"strings"
"testing"
)
// TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于
// 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位
// (>= / <),或口径被换成另一个数,这里立刻红。
func TestInlineImageLengthBoundary(t *testing.T) {
t.Parallel()
if err := checkInlineImageLength(strings.Repeat("A", MaxInlineImageChars)); err != nil {
t.Fatalf("恰好等于上限被拒:%v", err)
}
err := checkInlineImageLength(strings.Repeat("A", MaxInlineImageChars+1))
if !errors.Is(err, ErrImageTooLarge) {
t.Fatalf("error = %v, want ErrImageTooLarge", err)
}
msg := err.Error()
if !strings.Contains(msg, "上限") || !strings.Contains(msg, "699") {
t.Fatalf("文案没有带上限:%v", err)
}
if strings.Contains(msg, "AAAA") {
t.Fatalf("文案泄漏了载荷内容:%v", err)
}
}
// TestMaxInlineImageCharsMatchesByteBudget 钉住换算口径:字符上限换算回
// 解码字节必须装得下一张满额 5 MiB 图,余量只能是 data: 前缀那点字符,
// 不能顺带塞进第二张图 —— 与网关入口 checkChatImageSize 同一口径。
func TestMaxInlineImageCharsMatchesByteBudget(t *testing.T) {
t.Parallel()
if got := MaxInlineImageChars / 4 * 3; got < MaxInlineImageBytes {
t.Fatalf("字符上限只装得下 %d 字节,少于预算 %d", got, MaxInlineImageBytes)
}
if want := ((MaxInlineImageBytes+2)/3)*4 + InlineImagePrefixAllowance; MaxInlineImageChars != want {
t.Fatalf("MaxInlineImageChars = %d, want %d(base64 膨胀 4/3 + 前缀余量)", MaxInlineImageChars, want)
}
if MaxInlineImageChars > ((MaxInlineImageBytes+2)/3)*4+4096 {
t.Fatalf("余量过大(应只是 data: 前缀余量):%d", MaxInlineImageChars)
}
if MaxInlineImageBytes != 5<<20 {
t.Fatalf("MaxInlineImageBytes = %d, want 5 MiB(与上传/网关口徑同值)", MaxInlineImageBytes)
}
}
// TestDecodeBase64_RejectsOversizeBeforeDecoding 钉住「预检先于解码」:
// 用一段既超限又非法 base64 的载荷,错误必须是 ErrImageTooLarge 而不是
// ErrBadImage —— 后者只可能来自 base64.StdEncoding.DecodeString,
// 也就是「先按约 4/3 分配再报错」,正是要避免的那一步。
func TestDecodeBase64_RejectsOversizeBeforeDecoding(t *testing.T) {
t.Parallel()
oversize := strings.Repeat("!", MaxInlineImageChars+1)
if _, _, err := decodeBase64(oversize); !errors.Is(err, ErrImageTooLarge) {
t.Fatalf("裸载荷 error = %v, want ErrImageTooLarge(预检没有先于解码)", err)
}
if _, _, err := decodeBase64("data:image/png;base64," + oversize); !errors.Is(err, ErrImageTooLarge) {
t.Fatalf("data: URI error = %v, want ErrImageTooLarge", err)
}
}
// TestInlineImageSrc_RejectsOversizeDataURI 覆盖短路路径:data: URI 在
// 原样返回**之前**也要过预检(这条路径不解码,此前完全不看体积),
// 而恰好等于上限的合法串必须逐字节原样返回、不受影响。
func TestInlineImageSrc_RejectsOversizeDataURI(t *testing.T) {
t.Parallel()
prefix := "data:image/png;base64,"
oversize := prefix + strings.Repeat("A", MaxInlineImageChars)
if _, err := inlineImageSrc(context.Background(), oversize); !errors.Is(err, ErrImageTooLarge) {
t.Fatalf("error = %v, want ErrImageTooLarge", err)
}
exact := prefix + strings.Repeat("A", MaxInlineImageChars-len(prefix))
got, err := inlineImageSrc(context.Background(), exact)
if err != nil {
t.Fatalf("恰好等于上限的 data: URI 被拒:%v", err)
}
if got != exact {
t.Fatal("短路路径改动了原串(客户端 base64 路径必须逐字节不变)")
}
}
// TestDecodeBase64_ExactBudgetImagePasses 用一张**恰好 5 MiB** 的载荷把口径
// 从字符数真的换算回解码字节数:满额图片编码后的字符串必须仍在字符上限内
// (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果
// 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。
//
// 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量),
// 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由
// TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符
// (TestMaxInlineImageCharsMatchesByteBudget)。
func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) {
t.Parallel()
exact := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes)
dataURI := DataURI("image/png", exact)
if len(dataURI) > MaxInlineImageChars {
t.Fatalf("满额图片编码后 %d 字符,超过上限 %d(口径不一致)", len(dataURI), MaxInlineImageChars)
}
mime, data, err := decodeBase64(dataURI)
if err != nil {
t.Fatalf("满额图片被拒:%v", err)
}
if mime != "image/png" || !bytes.Equal(data, exact) {
t.Fatalf("满额图片解码结果不一致:mime=%q bytes=%d", mime, len(data))
}
}
// TestDecodeBase64_SmallPayloadUnchanged 钉住预检不伤正常载荷:一张小图的
// data: URI 解码后 MIME 与字节都不变。
func TestDecodeBase64_SmallPayloadUnchanged(t *testing.T) {
t.Parallel()
png := pngBytes(t)
mime, data, err := decodeBase64(DataURI("image/png", png))
if err != nil {
t.Fatalf("decodeBase64: %v", err)
}
if mime != "image/png" || !bytes.Equal(data, png) {
t.Fatalf("decodeBase64 = (%q, %d bytes), want (image/png, %d bytes)", mime, len(data), len(png))
}
if _, _, err := decodeBase64(base64.StdEncoding.EncodeToString(png)); err != nil {
t.Fatalf("裸 base64 被拒:%v", err)
}
}