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 拒」的配置耦合(待单一真源对齐)
This commit is contained in:
1 parent
c96a7db7f1
commit
e64ea0b156
6 files changed
+686
-8
No files matched your search
@@ -22,4 +22,10 @@ var (
|
||||
ErrNotImage = errors.New("vision: source is not an image")
|
||||
// ErrBadArgs 在工具参数格式错误时由该工具返回。
|
||||
ErrBadArgs = errors.New("vision: bad tool arguments")
|
||||
// ErrBlockedAddress 在图像 URL 指向被拒的链路本地/云元数据地址时返回
|
||||
// (见 guard.go)。错误只带主机与网段原因,不带 URL 或其中的凭据。
|
||||
ErrBlockedAddress = errors.New("vision: image URL points at a blocked address")
|
||||
// ErrImageTooLarge 在内联图像(base64 / data: URI)字符串超过上限时返回。
|
||||
// 判定在解码与短路之前完成,错误信息只带上限与本次长度。
|
||||
ErrImageTooLarge = errors.New("vision: inline image is too large")
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// 图像 URL 守卫:服务端会代取用户/渠道传入的任意 http(s) URL(F3 之后
|
||||
// 含 IM 陌生人的来图),因此取回链路必须自己拦下链路本地与云元数据地址,
|
||||
// 不能指望入口层过滤。这里只收紧「链路本地 + 云元数据」:环回、私网与
|
||||
// 公网保持原样可达 —— 同源自取需要它们(本服务要能取自己的
|
||||
// http://<局域网地址>:10942/api/v1/files/…)。
|
||||
//
|
||||
// 判定分三层,任一层命中都拒绝:
|
||||
// 1. 取回前对字面主机校验(guardImageURL):IP 字面量立即被拒,
|
||||
// DNS 名放行到下一层;
|
||||
// 2. 重定向每一跳重新校验(guardedCheckRedirect):跨主机跳转不得落到
|
||||
// 被拒网段;
|
||||
// 3. 真正建连前校验解析结果(guardedDialContext):解析到被拒地址的连接
|
||||
// 根本不会建立,也顺带让「先答公网 IP、再答元数据 IP」的 DNS 重绑失效。
|
||||
//
|
||||
// 三层只报「主机 + 网段原因」,绝不把 URL、userinfo 或查询串写进错误。
|
||||
|
||||
// maxImageRedirects 是一次取回最多跟随的重定向跳数。与 net/http 默认的 10
|
||||
// 同形,只是更小:取一张图不需要长跳转链,少跟几跳就少几次对陌生主机的连接。
|
||||
const maxImageRedirects = 5
|
||||
|
||||
// 被拒的云元数据地址以字符串常量给出(netip 的值只能是变量,而本项目约束
|
||||
// 不允许包级变量,只允许 var ErrXxx),判定时解析一次即可。
|
||||
const (
|
||||
// alibabaIMDSv4 是阿里云 ECS 元数据地址,落在 100.64.0.0/10 共享地址段内。
|
||||
alibabaIMDSv4 = "100.100.100.200"
|
||||
// awsIMDSv6Prefix 是 AWS 元数据专用 IPv6 前缀,fd00:ec2::254 在其内。
|
||||
// IPv6 ULA(fd00::/8)整体仍按私网放行,只拒这一个前缀。
|
||||
awsIMDSv6Prefix = "fd00:ec2::/64"
|
||||
)
|
||||
|
||||
// blockedAddressError 是被拒地址的错误:只携带主机与网段原因,
|
||||
// 绝不携带 URL 或其中的凭据,因此即使被 net/http 的 *url.Error 包裹
|
||||
// (重定向与拨号失败都走这条路径),也能用 errors.As 取回原因原样呈现。
|
||||
type blockedAddressError struct {
|
||||
host string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *blockedAddressError) Error() string {
|
||||
return fmt.Sprintf("%s: %s(主机 %s)", ErrBlockedAddress, e.reason, e.host)
|
||||
}
|
||||
|
||||
func (e *blockedAddressError) Unwrap() error { return ErrBlockedAddress }
|
||||
|
||||
// blockedImageAddrReason 报告 addr 是否属于被拒的链路本地/云元数据地址,
|
||||
// 是则给出原因(用于错误文案)。IPv4-mapped 形式
|
||||
// (::ffff:169.254.169.254)先经 Unmap 归一到 IPv4 再判定,绕不过去;
|
||||
// IPv6 ULA(fd00::/8)整体仍按私网放行,只拒云元数据专用的 fd00:ec2::/64。
|
||||
func blockedImageAddrReason(addr netip.Addr) (string, bool) {
|
||||
if !addr.IsValid() {
|
||||
return "", false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
// netip 的 IsLinkLocalUnicast 同时覆盖 IPv4 169.254.0.0/16
|
||||
// (含云元数据 169.254.169.254)与 IPv6 fe80::/10。
|
||||
if addr.IsLinkLocalUnicast() {
|
||||
return "链路本地地址 169.254.0.0/16(含云元数据 169.254.169.254)或 fe80::/10", true
|
||||
}
|
||||
if addr == netip.MustParseAddr(alibabaIMDSv4) {
|
||||
return "云元数据地址 " + alibabaIMDSv4 + "(阿里云 ECS)", true
|
||||
}
|
||||
// AWS IMDSv6:fd00:ec2::254 落在 AWS 元数据专用前缀内。
|
||||
if netip.MustParsePrefix(awsIMDSv6Prefix).Contains(addr) {
|
||||
return "云元数据地址 " + awsIMDSv6Prefix + "(AWS IMDSv6)", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// guardImageURL 校验一个待取回/待跳转的 URL:协议必须仍是 http/https,
|
||||
// 且主机不得是被拒地址。主机是 DNS 名时这里放行(字面量判不出解析结果),
|
||||
// 由 guardedDialContext 在建连前按解析结果继续拦。
|
||||
func guardImageURL(u *url.URL) error {
|
||||
if u == nil {
|
||||
return fmt.Errorf("vision: %w: 空的 URL", ErrUnsupportedSource)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("vision: %w: 仅支持 http/https", ErrUnsupportedSource)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("vision: %w: URL 缺少主机", ErrFetch)
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
reason, blocked := blockedImageAddrReason(addr)
|
||||
if !blocked {
|
||||
return nil
|
||||
}
|
||||
return &blockedAddressError{host: host, reason: reason}
|
||||
}
|
||||
|
||||
// guardedImageClient 返回带守卫的 http.Client **副本**,绝不改动传入的
|
||||
// client(调用方可能把它共享给上游 chat 请求等其他用途):重定向逐跳校验
|
||||
// 并限跳,未设超时时取下限超时(复用 imageFetchTimeout),传输层换成
|
||||
// 「建连前按解析结果校验」的副本。
|
||||
func guardedImageClient(base *http.Client) *http.Client {
|
||||
c := &http.Client{}
|
||||
if base != nil {
|
||||
*c = *base
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
c.Timeout = imageFetchTimeout
|
||||
}
|
||||
c.Transport = guardedImageTransport(base)
|
||||
c.CheckRedirect = guardedCheckRedirect
|
||||
return c
|
||||
}
|
||||
|
||||
// guardedImageTransport 返回带拨号守卫的传输层。用 *http.Transport 的副本
|
||||
// 是为了把校验下沉到真正建连的一刻(连接池是逐 Transport 的,因此副本也
|
||||
// 意味着每次取图新建连接;换取的是一张图都取不到被拒网段)。调用方给了
|
||||
// 非 *http.Transport 的自定义 RoundTripper 时保留它 —— 此时字面主机与
|
||||
// 重定向两层守卫仍然生效。走环境代理时拨号目标是代理本身(不是图片主机),
|
||||
// 那种形态下拦下被拒网段的是前两层:字面主机与重定向逐跳校验。
|
||||
func guardedImageTransport(base *http.Client) http.RoundTripper {
|
||||
if base != nil && base.Transport != nil {
|
||||
bt, ok := base.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return base.Transport
|
||||
}
|
||||
return guardedTransport(bt)
|
||||
}
|
||||
dt, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return http.DefaultTransport
|
||||
}
|
||||
return guardedTransport(dt)
|
||||
}
|
||||
|
||||
// guardedTransport 复制 t 并把拨号改为守卫版本。
|
||||
func guardedTransport(t *http.Transport) *http.Transport {
|
||||
clone := t.Clone()
|
||||
clone.DialContext = guardedDialContext(clone.DialContext)
|
||||
return clone
|
||||
}
|
||||
|
||||
// guardedCheckRedirect 校验重定向的每一跳:跳数有上限,跳转目标必须仍是
|
||||
// http/https 且不是被拒地址。跨主机跳转因此不能把取回引到 169.254.169.254
|
||||
// 或 fd00:ec2::254 上;即便这一层被绕过,第三层的拨号守卫也不会真正建连。
|
||||
func guardedCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxImageRedirects {
|
||||
// 这里不包 ErrFetch:可由 fetchURL 统一包一层,
|
||||
// 免得同一句话被套上两层前缀。
|
||||
return fmt.Errorf("重定向超过 %d 跳", maxImageRedirects)
|
||||
}
|
||||
return guardImageURL(req.URL)
|
||||
}
|
||||
|
||||
// dialFunc 与 net.Dialer.DialContext 同形,便于测试注入假拨号器。
|
||||
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// guardedDialContext 在真正建连**之前**校验目标地址的解析结果:只要解析
|
||||
// 结果里有被拒地址,整次连接就被拒(宁可不取这张图,也不让进程去连元数据
|
||||
// 服务);随后用校验过的 IP 直接连接,拨号器不会再解析一次,「先答公网 IP、
|
||||
// 再答元数据 IP」的 DNS 重绑因此无从生效。
|
||||
func guardedDialContext(base dialFunc) dialFunc {
|
||||
if base == nil {
|
||||
dialer := &net.Dialer{Timeout: imageFetchTimeout}
|
||||
base = dialer.DialContext
|
||||
}
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vision: %w: 目标地址无法解析", ErrFetch)
|
||||
}
|
||||
addrs, err := resolveImageHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if reason, blocked := blockedImageAddrReason(a); blocked {
|
||||
return nil, &blockedAddressError{host: host, reason: reason}
|
||||
}
|
||||
}
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
conn, err := base(ctx, network, net.JoinHostPort(a.Unmap().String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
return nil, fmt.Errorf("vision: %w: 主机 %s 没有可用地址", ErrFetch, host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
}
|
||||
|
||||
// resolveImageHost 把主机名解析为 IP 列表;主机已是 IP 字面量时直接返回它,
|
||||
// 不再查一次 DNS。
|
||||
func resolveImageHost(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vision: %w: 解析主机 %s 失败: %v", ErrFetch, host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("vision: %w: 主机 %s 没有解析结果", ErrFetch, host)
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// blockedAddressOf 从(可能被 *url.Error 包裹的)错误里取回被拒地址错误,
|
||||
// 使调用方能直接呈现「主机 + 网段原因」而不是带回整条 URL 的错误串。
|
||||
func blockedAddressOf(err error) (*blockedAddressError, bool) {
|
||||
var blocked *blockedAddressError
|
||||
if errors.As(err, &blocked) {
|
||||
return blocked, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fetchCause 返回取回失败的底层原因:*url.Error 会把整条 URL(含查询串里
|
||||
// 可能的凭据)拼进错误串,这里只取内层错误,哨兵链因此也保留下来。
|
||||
func fetchCause(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,7 +22,8 @@ const (
|
||||
|
||||
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
|
||||
// 为模型代取 URL,没有上限时一个卡住或恶意的地址会一直占着这一轮
|
||||
// (fetchURL 复用默认 client,其本身不设整体超时)。
|
||||
// (fetchURL 经 guardedImageClient 给未设超时的 client 补上它,
|
||||
// 拨号守卫也用同一个值作单次建连上限)。
|
||||
const imageFetchTimeout = 30 * time.Second
|
||||
|
||||
// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。
|
||||
@@ -66,7 +68,12 @@ func readFile(path string) (string, []byte, error) {
|
||||
}
|
||||
|
||||
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
|
||||
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
|
||||
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。
|
||||
func decodeBase64(src string) (string, []byte, error) {
|
||||
if err := checkInlineImageLength(src); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mime, encoded, isDataURI := splitDataURI(src)
|
||||
if isDataURI {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
@@ -83,22 +90,35 @@ func decodeBase64(src string) (string, []byte, error) {
|
||||
}
|
||||
|
||||
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
|
||||
// 取回前、重定向每一跳与真正建连前都有守卫(见 guard.go):链路本地与
|
||||
// 云元数据地址被拒,环回/私网/公网照旧可达(同源自取需要它们)。
|
||||
// 声明的 Content-Type 只作参考,真实字节嗅探说了算(见 imageMIMEFor):
|
||||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||||
}
|
||||
if client == nil {
|
||||
client = defaultHTTPClient()
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// 不回显 URL:解析失败的原文里带着整条地址(可能含凭据)。
|
||||
return "", nil, fmt.Errorf("vision: %w: URL 无法解析", ErrFetch)
|
||||
}
|
||||
if err := guardImageURL(u); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
guarded := guardedImageClient(client)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||||
return "", nil, fmt.Errorf("vision: %w: 无法构造请求", ErrFetch)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := guarded.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||||
// 被拒地址直接返回类型化错误:它只含主机与网段原因,
|
||||
// 不像 client.Do 的 *url.Error 那样把整条 URL(可能含凭据)带出来。
|
||||
if blocked, ok := blockedAddressOf(err); ok {
|
||||
return "", nil, blocked
|
||||
}
|
||||
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, fetchCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -117,10 +137,14 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string,
|
||||
|
||||
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
|
||||
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
|
||||
// 逐字节不变)。URL 取回复用 Resolve→fetchURL 的既有守卫
|
||||
// (协议白名单 + 体积上限),并加取回超时。
|
||||
// 逐字节不变),但**短路之前**也要过长度预检:这条路径不解码,不预检
|
||||
// 就等于把体积上限完全交给入口层。URL 取回复用 Resolve→fetchURL 的既有
|
||||
// 守卫(协议白名单 + 体积上限 + 地址守卫),并加取回超时。
|
||||
func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
if err := checkInlineImageLength(src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
ref, err := ParseImageRef(src)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package vision
|
||||
|
||||
import "fmt"
|
||||
|
||||
// 内联图像(base64 / data: URI)的体积预算:与网关 REST 入口
|
||||
// internal/services/gateway/rest.go 的图片上限同口径 —— 5 MiB 解码后字节,
|
||||
// 外加 64 字符的 data: 前缀余量。网关是**入口层**实现,这里是**解码层**的
|
||||
// 同一口径兜底:REST 入口已有 5 MiB 上限,但任何未来新入口,或把 WS 上限
|
||||
// 放宽,都会重新打开「先按约 4/3 分配再报错」这个洞(G3 的纵深)。
|
||||
//
|
||||
// 口径换算只有一处:(n+2)/3*4 是 base64 每 3 字节编码为 4 字符的膨胀系数
|
||||
// (余 1、2 字节时末组仍需补满 4 字符),再加 data: 前缀余量 —— 恰好
|
||||
// 5 MiB 的合法图片编码后正好等于该上限,不会被误拒。
|
||||
//
|
||||
// 注意生效范围:网关的生效上限可由配置 CHAT_IMAGE_MAX_BYTES 调整(默认
|
||||
// 5242880 与本常量同值),本包读不到网关配置,因此这里钉住的是**默认口径的
|
||||
// 硬顶** —— 解码层不随入口配置放宽;入口层比它更严时以入口层为准。
|
||||
const (
|
||||
// MaxInlineImageBytes 是一张内联图像解码后的字节上限(5 MiB),
|
||||
// 与网关 POST /api/v1/upload 的 maxUploadBytes 同口径。
|
||||
MaxInlineImageBytes = 5 << 20
|
||||
// InlineImagePrefixAllowance 是 data: URI 前缀(如
|
||||
// "data:image/png;base64,")所占的字符余量,不含 base64 载荷本身。
|
||||
InlineImagePrefixAllowance = 64
|
||||
// MaxInlineImageChars 是同一口径换算到 base64 字符串长度的上限。
|
||||
MaxInlineImageChars = ((MaxInlineImageBytes+2)/3)*4 + InlineImagePrefixAllowance
|
||||
)
|
||||
|
||||
// checkInlineImageLength 对 base64 / data: URI 字符串做长度预检,在任何
|
||||
// 解码或短路之前拦下超限载荷:解码要先按约 4/3 分配,不预检就是「先分配
|
||||
// 再报错」,放宽了入口上限的新入口会因此被放大成内存压力。判定按**字符串
|
||||
// 长度**(零分配),与网关入口 checkChatImageSize 同一口径。
|
||||
//
|
||||
// 错误文案只带上限与本次长度,绝不包含图片内容。
|
||||
func checkInlineImageLength(src string) error {
|
||||
if len(src) <= MaxInlineImageChars {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("vision: %w: 内联图像上限 %d 字符(解码后约 %d MiB),本次 %d 字符",
|
||||
ErrImageTooLarge, MaxInlineImageChars, MaxInlineImageBytes>>20, len(src))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user