- 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 拒」的配置耦合(待单一真源对齐)
129 lines
5.6 KiB
Go
129 lines
5.6 KiB
Go
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)
|
||
}
|
||
}
|