// Package mediafetch 实现 IM 适配器**入站附件字节内联**的共用件(切片 IM-1): // 把「平台载荷给出的一个资产引用」取回成字节,交回给适配器写进 // `adapter.Media.Data`,使 channelbridge 的入站摄取(M1)真的读到文件内容。 // // 三条安全口径(切片 IM-1 的硬约束,逐条都是红线): // // 1. **只取平台载荷给出的引用**:本包只接受调用方(各适配器)从平台原生载荷里 // 解析出来的资产引用 —— 由各适配器的 Fetcher 负责构造;本包**从不**解析消息 // 正文,也不从正文里找 URL(与 F3 的 SSRF 教训一致:服务端代取「用户指定的 // 任意 URL」是另一回事,需要专门裁决)。 // // 2. **来源白名单、deny-by-default**:请求的 `scheme://host[:port]` 必须命中调用 // 方声明的来源(平台 CDN / 平台 API 主机 / 部署自己声明的 OneBot 端点)。不在 // 白名单里 ⇒ **一个字节都不发出去**;重定向的每一跳也按同一份白名单校验,因此 // 白名单不能靠一次 302 被绕过。它比「先解析 IP 再判网段」更严:白名单之外的任何 // 主机(云元数据、内网、任意公网)都不可达。 // // 与既有两处守卫的关系(**本包不是第三份 SSRF 实现**):`plugins/tools/io` // 的 `validateRequestURL` 与 `internal/runtime/multimodal/vision` 的取图守卫 // 都是「先放行、再按 IP 网段否决」的形状,且**刻意**允许环回/私网(同源自取 // 需要它们);入站 IM 附件不需要那份宽容 —— 它只需要够到平台自己的资产主机, // 所以这里用「按来源白名单短路」而非网段判定。视觉链路的守卫本切片**未改动**。 // // 3. **尺寸与时间双上限**:边读边限(`io.LimitReader`,超限即放弃),每份下载有 // 具名超时(默认 `DefaultTimeout`)并接 ctx 取消。 // // 错误一律**不含完整 URL**(平台资产地址可能带签名凭据,例如 QQ CDN 的 `rkey`), // 只带来源与原因:这让错误可以安全进日志与模型可见视图。 package mediafetch import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) const ( // DefaultTimeout 是单份附件下载的默认超时上限(具名常量,全仓唯一出处)。 // 它只约束这一份下载:父 ctx 取消(例如渠道断开、回合结束)会立即一并取消。 DefaultTimeout = 30 * time.Second // maxRedirects 是一次取回最多跟随的重定向跳数。比 net/http 默认的 10 更小: // 取一份平台附件不需要长跳转链,少跟几跳就少几次对陌生主机的连接(每一跳都 // 仍按白名单校验)。 maxRedirects = 3 // wildcardPrefix / schemeSep 是白名单条目里通配与协议分隔的写法定界,避免魔法 // 字符串散落在判定逻辑里。 wildcardPrefix = "*." schemeSep = "://" // unknownHost 是日志里 host 字段取不到来源时的占位值。 unknownHost = "未知来源" ) // 取回路径的哨兵错误。调用方一律用 errors.Is 分类,**绝不按错误文本匹配**。 var ( // ErrOriginNotAllowed 报告请求的来源(或重定向目标)不在白名单内 —— 唯一一个 // 「我们主动不发请求」的哨兵:它与「发了但失败」必须能分开(前者 0 次命中)。 ErrOriginNotAllowed = errors.New("mediafetch: 资产来源不在白名单内") // ErrBadScheme 报告资产引用不是 http/https。 ErrBadScheme = errors.New("mediafetch: 资产引用只支持 http/https") // ErrNoHost 报告资产引用没有主机。 ErrNoHost = errors.New("mediafetch: 资产引用缺少主机") // ErrEmptyURL 报告资产引用为空。 ErrEmptyURL = errors.New("mediafetch: 资产引用为空") // ErrTooLarge 报告附件超过体积上限(边读边限,超限即放弃)。 ErrTooLarge = errors.New("mediafetch: 附件超过体积上限") // ErrBadStatus 报告平台资产响应的状态码不是 2xx。 ErrBadStatus = errors.New("mediafetch: 平台资产响应非成功状态") // ErrTooManyRedirects 报告重定向超过上限。 ErrTooManyRedirects = errors.New("mediafetch: 重定向超过上限") ) // Allowlist 是一组允许连接的来源(`scheme://host[:port]`,可写成 // `https://*.example.com` 表示整棵子树)。**零值表示谁都不许**:deny-by-default。 type Allowlist struct { origins []string } // NewAllowlist 构造白名单。条目按字面归一(小写、去尾斜杠);**无法解析的条目一律 // 不匹配任何来源**(fail-closed:写错的条目不会意外放行任何主机)。 func NewAllowlist(origins ...string) Allowlist { out := make([]string, 0, len(origins)) for _, o := range origins { e := normalizeEntry(o) if e != "" { out = append(out, e) } } return Allowlist{origins: out} } // Empty 报告白名单是否没有任何条目(任何请求都会被拒)。 func (a Allowlist) Empty() bool { return len(a.origins) == 0 } // Allowed 报告 rawURL 的来源是否在白名单内。 func (a Allowlist) Allowed(rawURL string) bool { origin, err := OriginOf(rawURL) if err != nil { return false } for _, e := range a.origins { if matchOrigin(e, origin) { return true } } return false } // OriginOf 返回绝对 URL 的来源字符串(`scheme://host[:port]`,协议默认端口被 // 归一掉、主机小写)。它只用于**比对与日志**,绝不用来重建请求地址。 func OriginOf(rawURL string) (string, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return "", fmt.Errorf("mediafetch: 解析资产地址: %w", err) } return originOfURL(u) } // Request 是一次取回的输入。URL 必须由调用方(适配器)从**平台载荷**构造;本包 // 不提供任何「从消息正文里找 URL」的入口。 type Request struct { // URL 是平台载荷给出的资产地址(绝对 http/https URL)。 URL string // Allow 是允许连接的来源白名单。空名单 ⇒ 一律拒绝(deny-by-default)。 Allow Allowlist // Header 是随首次请求发送的附加头(例如平台凭据)。跟随重定向时由 net/http // 按自身规则决定是否携带(跨主机时敏感头会被剥离),调用方无需处理。 Header http.Header // MaxBytes 是单份附件的字节上限;≤0 时取 documents.DefaultMaxBytes(20 MiB, // 与 Web 上传同源,绝不在适配器里另写一个数)。 MaxBytes int64 // Timeout 是本次取回的超时;≤0 时取 DefaultTimeout。 Timeout time.Duration // Client 是可选的 HTTP 客户端(适配器已有的客户端)。取回时用它的**副本** // (不改动调用方的客户端),只覆盖重定向校验。nil 时新建一个。 Client *http.Client } // Fetch 取回一份平台资产并返回其字节。失败一律返回错误(绝不返回「空字节 + nil」: // 上游据此区分「拿到了空文件」与「没取到」)。 func Fetch(ctx context.Context, req Request) ([]byte, error) { if strings.TrimSpace(req.URL) == "" { return nil, ErrEmptyURL } origin, err := OriginOf(req.URL) if err != nil { return nil, err } if !req.Allow.Allowed(req.URL) { // 白名单未命中:**不发请求**。这条路径是可断言的「0 次命中」。 return nil, fmt.Errorf("%w: %s", ErrOriginNotAllowed, origin) } ctx, cancel := context.WithTimeout(ctx, positiveDuration(req.Timeout, DefaultTimeout)) defer cancel() client := fetchClient(req.Client, req.Allow) maxBytes := positiveBytes(req.MaxBytes) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, req.URL, nil) if err != nil { return nil, fmt.Errorf("mediafetch: 构造 %s 的取回请求: %w", origin, err) } applyHeader(httpReq.Header, req.Header) resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, trace.URLCause(err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("%w: %d(%s)", ErrBadStatus, resp.StatusCode, origin) } // 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。 data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, trace.URLCause(err)) } if int64(len(data)) > maxBytes { return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin) } return data, nil } // HostOf 返回 rawURL 的来源(用于日志),解析不出来时给占位值 —— 日志里**只有来源**, // 绝不出现完整 URL(可能带凭据)。 func HostOf(rawURL string) string { origin, err := OriginOf(rawURL) if err != nil { return unknownHost } return origin } // fetchClient 返回带「逐跳白名单校验」的客户端副本:绝不改动调用方的客户端(它可能 // 正被上游请求等其他用途共享)。调用方原本的 CheckRedirect 会被覆盖 —— 入站附件 // 的取回安全由本包的白名单独占负责,不叠加别处的重定向策略。 func fetchClient(base *http.Client, allow Allowlist) *http.Client { c := &http.Client{} if base != nil { *c = *base } c.CheckRedirect = func(r *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return ErrTooManyRedirects } if r == nil || r.URL == nil { return ErrNoHost } if !allow.Allowed(r.URL.String()) { return fmt.Errorf("%w: 重定向到 %s", ErrOriginNotAllowed, HostOf(r.URL.String())) } return nil } return c } // applyHeader 把附加头装进请求。 func applyHeader(dst, src http.Header) { for k, vs := range src { for _, v := range vs { dst.Add(k, v) } } } // originOfURL 把一个已解析的 URL 归一成来源字符串。 func originOfURL(u *url.URL) (string, error) { if u == nil { return "", ErrEmptyURL } scheme := strings.ToLower(u.Scheme) if scheme != "http" && scheme != "https" { return "", fmt.Errorf("%w: %q", ErrBadScheme, scheme) } host := strings.ToLower(u.Hostname()) if host == "" { return "", ErrNoHost } port := u.Port() if port == "" || port == defaultPort(scheme) { return scheme + schemeSep + joinHostPort(host, ""), nil } return scheme + schemeSep + joinHostPort(host, port), nil } // joinHostPort 拼主机与端口名,IPv6 字面量会被重新加回方括号。 func joinHostPort(host, port string) string { if strings.Contains(host, ":") { if port == "" { return "[" + host + "]" } return "[" + host + "]:" + port } if port == "" { return host } return host + ":" + port } // defaultPort 给出协议的默认端口(归一用)。 func defaultPort(scheme string) string { if scheme == "https" { return "443" } return "80" } // normalizeEntry 归一一条白名单条目:小写、去尾斜杠、去掉路径部分。无法解析的条目 // 返回空串(随后被丢弃 ⇒ 永不匹配任何来源)。 func normalizeEntry(entry string) string { e := strings.ToLower(strings.TrimSpace(entry)) if e == "" { return "" } if !strings.Contains(e, schemeSep) { return "" } // 只保留 scheme://host[:port],丢掉任何路径(条目写成 URL 时按来源处理)。 trimmed := strings.TrimRight(e, "/") if i := strings.Index(trimmed, schemeSep); i >= 0 { rest := trimmed[i+len(schemeSep):] if j := strings.IndexByte(rest, '/'); j >= 0 { trimmed = trimmed[:i+len(schemeSep)+j] } } return trimmed } // matchOrigin 报告已归一的 origin 是否被条目 entry 覆盖:精确相等,或条目是 // `scheme://*.suffix` 形态且 origin 的主机落在该子树内(`*.qq.com` 覆盖 // `a.qq.com` 与 `a.b.qq.com`,但不覆盖 `qq.com` 自身)。 func matchOrigin(entry, origin string) bool { if entry == origin { return true } i := strings.Index(entry, schemeSep) if i < 0 { return false } scheme, hostPat := entry[:i], entry[i+len(schemeSep):] if !strings.HasPrefix(hostPat, wildcardPrefix) { return false } suffix := hostPat[len(wildcardPrefix)-1:] // ".qq.com" j := strings.Index(origin, schemeSep) if j < 0 || origin[:j] != scheme { return false } host := stripPort(origin[j+len(schemeSep):]) return strings.HasSuffix(host, suffix) && len(host) > len(suffix) } // stripPort 去掉来源主机里的端口(IPv6 字面量的方括号形态一并处理)。 func stripPort(hostport string) string { if strings.HasPrefix(hostport, "[") { if k := strings.Index(hostport, "]"); k >= 0 { return hostport[1:k] } return hostport } if k := strings.LastIndex(hostport, ":"); k >= 0 { return hostport[:k] } return hostport } // positiveDuration 返回 d(d ≤ 0 时用 fallback),使路径里不出现裸的「0 表示默认」。 func positiveDuration(d, fallback time.Duration) time.Duration { if d <= 0 { return fallback } return d } // positiveBytes 返回上限字节数(≤0 时取 documents.DefaultMaxBytes)。 func positiveBytes(n int64) int64 { if n <= 0 { return documents.DefaultMaxBytes } return n }