五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause / vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为 trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个 未导出符号与其文件内注释整份删除(不留薄封装)。 行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、 *net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的 收紧: - 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址); - *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的 替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。 顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在 *url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑), 用户名才是分水岭。 验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿; 变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
359 lines
13 KiB
Go
359 lines
13 KiB
Go
// 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
|
||
}
|