Files
Cyrene_v2/internal/runtime/multimodal/vision/guard.go
T
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

242 lines
9.4 KiB
Go
Raw 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 (
"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
}