Files
AskaEth e64ea0b156
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 拒」的配置耦合(待单一真源对齐)
2026-10-04 16:24:56 +08:00

239 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}