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,238 @@
package vision
import (
"bytes"
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestBlockedImageAddrReason 钉住被拒网段的判定:链路本地
// (IPv4/IPv6,含 IPv4-mapped 形式)与已知云元数据地址一律拒绝;环回、
// 私网(含 IPv6 ULA)与公网必须放行 —— 后者是同源自取
// (本服务的局域网 URL)能继续工作的前提。
func TestBlockedImageAddrReason(t *testing.T) {
t.Parallel()
blocked := []string{
"169.254.169.254", // 云元数据(AWS/GCP/Azure/Azure IMDSv4)
"169.254.0.23", // 腾讯云元数据(同一链路本地网段)
"::ffff:169.254.169.254", // IPv4-mapped 形式不得绕过
"::ffff:169.254.0.23",
"fe80::1", // fe80::/10
"fe80::a9fe:a9fe",
"fd00:ec2::254", // AWS IMDSv6
"100.100.100.200", // 阿里云元数据
}
for _, s := range blocked {
if reason, got := blockedImageAddrReason(netip.MustParseAddr(s)); !got {
t.Errorf("%s 未被拒绝", s)
} else if reason == "" {
t.Errorf("%s 被拒但没有给出原因文案", s)
}
}
allowed := []string{
"127.0.0.1", "::1", // 环回:测试与同源自取都要用
"192.168.1.10", "10.0.0.5", "172.16.9.1", // 私网 IPv4:同源自取的常态
"fd00::1", "fd12:3456::1", // IPv6 ULA 整体仍按私网放行
"8.8.8.8", "2001:db8::1", // 公网
}
for _, s := range allowed {
if reason, got := blockedImageAddrReason(netip.MustParseAddr(s)); got {
t.Errorf("%s 被误拒(同源自取会被打断):%s", s, reason)
}
}
}
// TestResolve_RejectsMetadataAndLinkLocalLiterals 是 SSRF 收紧的主用例:
// 字面量元数据/链路本地地址必须在发起任何连接之前被拒;错误里不得出现
// URL 与凭据,只允许出现主机与网段原因。
func TestResolve_RejectsMetadataAndLinkLocalLiterals(t *testing.T) {
t.Parallel()
cases := []string{
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://[::ffff:169.254.169.254]/latest/meta-data/",
"http://[fe80::1]:10942/api/v1/files/x.png",
"http://[fd00:ec2::254]/latest/meta-data/",
"http://user:secret@169.254.169.254/latest/meta-data/?token=abc",
"https://100.100.100.200/latest/meta-data/",
}
for _, u := range cases {
_, _, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: u}, nil)
if !errors.Is(err, ErrBlockedAddress) {
t.Errorf("Resolve(%s) error = %v, want ErrBlockedAddress", u, err)
continue
}
msg := err.Error()
for _, leak := range []string{"http://", "https://", "secret", "token=", "meta-data"} {
if strings.Contains(msg, leak) {
t.Errorf("错误泄漏了 URL/凭据(含 %q):%v", leak, err)
}
}
}
}
// TestResolve_AllowsSelfOriginatedURL 是「没打断自取」的端到端证据:
// 同源自取的地址形状正是
// http://<主机>:10942/api/v1/files/<32 位十六进制名>.<ext>。用 httptest
// (绑定环回)模拟同源服务,走完整 Resolve→fetchURL→守卫链路,证明
// 环回/私网仍可取回,且守卫没有改坏取回本身(MIME 与字节都不变)。
func TestResolve_AllowsSelfOriginatedURL(t *testing.T) {
t.Parallel()
png := pngBytes(t)
srv := newImageServer(t, png, "image/png", nil)
selfURL := srv.URL + "/api/v1/files/" + strings.Repeat("a", 32) + ".png"
mime, data, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: selfURL}, nil)
if err != nil {
t.Fatalf("Resolve(%s): %v(同源自取被守卫打断)", selfURL, err)
}
if mime != "image/png" || !bytes.Equal(data, png) {
t.Fatalf("Resolve = (%q, %d bytes), want (image/png, %d bytes)", mime, len(data), len(png))
}
}
// TestGuardedDialContext_LayerThree 钉住第三层(建连前按解析结果校验):
// 局域网自取地址必须原样交给底层拨号器(即不打断自取),而被拒网段在
// 建连之前失败,且底层拨号器**根本不会被调用**(没有真正的连接发出)。
func TestGuardedDialContext_LayerThree(t *testing.T) {
t.Parallel()
var dialed []string
base := func(_ context.Context, _, addr string) (net.Conn, error) {
dialed = append(dialed, addr)
return nil, errors.New("dial stub")
}
dial := guardedDialContext(base)
if _, err := dial(context.Background(), "tcp", "192.168.1.10:10942"); err == nil {
t.Fatal("底层拨号桩必然失败,这里应回传错误")
}
if len(dialed) != 1 || dialed[0] != "192.168.1.10:10942" {
t.Fatalf("局域网地址未被交给底层拨号器:%v(同源自取会被打断)", dialed)
}
_, err := dial(context.Background(), "tcp", "169.254.169.254:80")
if !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("error = %v, want ErrBlockedAddress", err)
}
if len(dialed) != 1 {
t.Fatalf("被拒地址仍然发起了连接:%v", dialed)
}
}
// TestFetchURL_RejectsRedirectToBlockedAddress 钉住第二层:跨主机重定向
// 不得把取回引到元数据/链路本地网段,被拒的那一跳根本不会发出(源服务
// 只收到第一次请求),且错误里不带 URL。
func TestFetchURL_RejectsRedirectToBlockedAddress(t *testing.T) {
t.Parallel()
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
t.Cleanup(srv.Close)
_, _, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: srv.URL + "/redirect"}, nil)
if !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("error = %v, want ErrBlockedAddress", err)
}
if hits.Load() != 1 {
t.Fatalf("源服务收到 %d 次请求,want 1(被拒的跳不得发出)", hits.Load())
}
if msg := err.Error(); strings.Contains(msg, "http://") || strings.Contains(msg, "meta-data") {
t.Fatalf("错误泄漏了 URL:%v", err)
}
}
// TestFetchURL_LimitsRedirectChain 钉住重定向上限:自我循环的重定向不得
// 把一次取回变成无限跟随(每多跟一跳就多连一次陌生主机)。
func TestFetchURL_LimitsRedirectChain(t *testing.T) {
t.Parallel()
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.Redirect(w, r, "/again", http.StatusFound)
}))
t.Cleanup(srv.Close)
_, _, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: srv.URL + "/loop"}, nil)
if err == nil {
t.Fatal("自我循环的重定向未报错")
}
if !errors.Is(err, ErrFetch) {
t.Fatalf("error = %v, want ErrFetch", err)
}
if got := hits.Load(); got != maxImageRedirects {
t.Fatalf("请求了 %d 次,want %d(= 重定向上限)", got, maxImageRedirects)
}
}
// TestFetchURL_StillRejectsHTMLAtAllowedAddress 钉住既有断言在换上新守卫
// client 之后仍然成立:守卫只拦地址,不改变字节嗅探的否决权 —— 允许地址
// 上的 text/html 错误页仍明确报 ErrNotImage,且不泄漏页面内容。
func TestFetchURL_StillRejectsHTMLAtAllowedAddress(t *testing.T) {
t.Parallel()
srv := newImageServer(t, htmlBytes(), "text/html; charset=utf-8", nil)
_, _, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: srv.URL + "/err"}, nil)
if !errors.Is(err, ErrNotImage) {
t.Fatalf("error = %v, want ErrNotImage", err)
}
if strings.Contains(err.Error(), "502 Bad Gateway") {
t.Fatalf("错误泄漏了响应体:%v", err)
}
}
// TestFetchURL_FailureDoesNotEchoURL 钉住取回失败的文案不外泄 URL/凭据:
// *url.Error 会把整条 URL(查询串里的签名也是凭据)拼进错误串,
// 因此失败路径必须剥掉它,只留底层原因,并仍可被 ErrFetch 判定。
func TestFetchURL_FailureDoesNotEchoURL(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
target := srv.URL + "/cat.png?token=SECRETSIG"
srv.Close() // 关闭后取回必然失败(端口无人监听)
_, _, err := Resolve(context.Background(), ImageRef{Source: SourceURL, Data: target}, nil)
if err == nil {
t.Fatal("取回已关闭的地址未报错")
}
if !errors.Is(err, ErrFetch) {
t.Fatalf("error = %v, want ErrFetch", err)
}
if msg := err.Error(); strings.Contains(msg, "SECRETSIG") || strings.Contains(msg, "token=") || strings.Contains(msg, "http://") {
t.Fatalf("取回失败的文案外泄了 URL/凭据:%v", err)
}
}
// TestGuardedImageClient_DoesNotMutateCaller 钉住「不改调用方 client」:
// 适配器把同一个 client 也用于上游 chat 请求,若守卫原地改它,上游请求会
// 被套上取图专用的重定向/拨号守卫(跨用途的副作用)。
func TestGuardedImageClient_DoesNotMutateCaller(t *testing.T) {
t.Parallel()
base := &http.Client{}
guarded := guardedImageClient(base)
if base.CheckRedirect != nil || base.Transport != nil || base.Timeout != 0 {
t.Fatalf("调用方 client 被原地修改:%+v", base)
}
if guarded == base {
t.Fatal("守卫必须返回副本,而不是同一个 client")
}
if guarded.CheckRedirect == nil {
t.Fatal("副本没有装重定向守卫")
}
if guarded.Timeout != imageFetchTimeout {
t.Fatalf("副本超时 = %v, want %v(复用既有取回超时)", guarded.Timeout, imageFetchTimeout)
}
// 调用方显式设过超时时不得被覆盖。
withTimeout := &http.Client{Timeout: time.Minute}
if got := guardedImageClient(withTimeout).Timeout; got != time.Minute {
t.Fatalf("调用方超时被改写:%v", got)
}
}