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