Files
Cyrene_v2/plugins/channels/weixin_ilink/media.go
T
AskaEth 6a545badbd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00

192 lines
8.3 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 weixin_ilink
import (
"context"
"crypto/aes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
// deriveInboundKey 用 bot_token 推导 AES-128 密钥:base64 解码 token,取末尾 16 字节。
//
// ⚠️ 这是**兜底**,不是主路径。真机核对的结论见 media_key.go 的文件头:官方包从不
// 从 bot_token 推导媒体密钥(密钥按条随附件下发),而真实 bot_token 形如
// `<12 位>@im.bot:<38 位十六进制>`、根本不是 base64 —— 对它调用本函数**必然**失败
// (第 12 个字符 `@` 处非法)。保留它只为「token 确实以 base64 下发」的部署与
// `DownloadMedia` 的既有契约。
//
// 解码容忍 URL-safe 与无 padding 的 base64 变体:凭据由第三方下发,形态不由我们决定,
// 先猜一种再报「illegal base64」正是真机踩过的那个坑。
func deriveInboundKey(botToken string) ([]byte, error) {
token := strings.TrimSpace(botToken)
if token == "" {
return nil, ErrInvalidConfig
}
b, err := decodeBase64Flexible(token)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
}
if len(b) < aes.BlockSize {
return nil, ErrKeyTooShort
}
return b[len(b)-aes.BlockSize:], nil
}
// botToken 返回已配置的 bot token 快照(凭据只在锁内读,绝不进日志)。
func (a *Adapter) botToken() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotToken
}
// DownloadMedia 下载一条入站 CDN 引用,并用从已配置 bot_token 推导出的密钥返回
// 明文。密文超过 MaxMediaSize 时以 ErrMediaTooLarge 失败。
//
// 只有 URL、没有原始消息载荷的调用方走这条路径(按条密钥无从查起);轮询投递出来的
// 附件走 ilinkFetcher,那条路径优先用平台随附件下发的密钥。
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
key, err := deriveInboundKey(a.botToken())
if err != nil {
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// DownloadMediaWithKey 是 DownloadMedia 的显式按条 AES 密钥版本,用于协议把密钥
// 与媒体引用一并下发的场景。
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
if len(key) != aes.BlockSize {
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// downloadWithKey 拉取 CDN 字节并剥掉 AES-ECB 层。
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
}
plaintext, err := aesECBDecrypt(key, ciphertext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
}
return plaintext, nil
}
// fetchBody 对 rawURL 发 GET 并返回响应体,超过 MaxMediaSize 的一律拒绝,避免单个
// 超大文件耗尽内存。
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: build media request: %w", err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode)
}
limit := a.cfg.MaxMediaSize + 1
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: read media: %w", err)
}
if int64(len(data)) > a.cfg.MaxMediaSize {
return nil, ErrMediaTooLarge
}
return data, nil
}
// inlineInboundMedia 把受支持格式的附件(**文件类、图片类、语音类**)取回、解密并内联到
// 消息上(切片 IM-1 / IM-ASR / IM-IMG)。失败不抛出:该附件保持无字节,由 channelbridge 的
// 入站摄取照既有口径给模型一行诚实提示(design/24 §4.4)——本切片绝不为了「让附件能读」
// 而把失败吞掉。
//
// 图片走的是**同一条路**:iLink 的 image_item 不带文件名也不带 MIME,因此它的可取性由
// 平台类型 + 字节嗅探判定(`mediafetch.inlineImages`),来源仍受下面这份白名单约束。
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData})
}
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、按类型的体积上限,以及
// **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。
type ilinkFetcher struct {
a *Adapter
// raw 是这条消息的原始 iLink 载荷。按条 AES 密钥随附件下发,而归一化契约里没有
// 承载它的字段,因此密钥按需从这里回查(见 inboundMediaKey)。
raw []byte
}
// Fetch 取回并解密一份微信 iLink 附件(**文件、图片、语音、视频同一条路**)。体积上限与
// 超时都不在这里写数:上限按媒体类型由 `mediafetch.MaxBytesFor` 给出(图片 5 MiB、语音
// 5 MiB、文档类附件 20 MiB —— 一份巨图绝不会按文档的 20 MiB 收下),超时是具名常量
// `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限,
// 与本切片的内联上限刻意解耦)。
func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
key, err := f.inboundKey(m)
if err != nil {
return nil, err
}
ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{
URL: f.a.cdnURL(m.URL),
Allow: f.a.assetAllowlist(),
Header: f.a.inboundHeaders(),
Client: f.a.httpClient,
MaxBytes: mediafetch.MaxBytesFor(m),
Timeout: mediafetch.DefaultTimeout,
})
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
}
plaintext, err := aesECBDecrypt(key, ciphertext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
}
return plaintext, nil
}
// inboundKey 决定本次解密用哪个密钥:**优先平台随附件下发的按条 aes_key**(官方包的
// 入站口径),只有这条消息确实没带密钥时才退回 bot_token 推导的兜底。两条都拿不到时
// 诚实报错 —— 绝不拿一个来路不明的密钥去解,那只会解出乱码。
func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) {
if key, ok := f.a.inboundMediaKey(f.raw, m.URL); ok {
return key, nil
}
key, err := deriveInboundKey(f.a.botToken())
if err != nil {
return nil, fmt.Errorf("weixin_ilink: no per-item aes_key and bot_token fallback: %w", err)
}
return key, nil
}
// assetAllowlist 给出入站附件取回的来源白名单:**只有已配置的 CDN 根**(默认
// `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用
// 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute),
// 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。
//
// 它对**所有**媒体类型一视同仁:文件、图片、语音、视频都经 `ilinkFetcher.Fetch` 取回,
// 因此图片 URL(绝对 `full_url` 或 CDN 根下的相对引用)同样受这一份白名单约束 ——
// 图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限,不是更宽的网络可达面。
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL)
if err != nil {
return mediafetch.NewAllowlist()
}
return mediafetch.NewAllowlist(origin)
}