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 位十六进制名>.。用 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) } }