Files
Cyrene_v2/internal/channel/mediafetch/mediafetch.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

370 lines
14 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 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"
)
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, sanitizeErr(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, sanitizeErr(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)
}
}
}
// sanitizeErr 剥掉 *url.Error 的外壳:它会把**完整 URL**(含查询串里的签名凭据)
// 拼进错误串。剥壳同时保留内层哨兵链(`context.DeadlineExceeded` 等仍可用
// errors.Is 判定)。内层已是最终错误时**原样透传**(这里只做剥壳,不新增包装 ——
// 再包一层只会把同一个原因说两遍)。
func sanitizeErr(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// 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
}