feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 超量用例)
This commit is contained in:
AskaEth committed 2026-10-04 22:13:17 +08:00
1 parent 46caa724f2
commit 3be70f8232
22 files changed
+2601 -19

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
// 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
}