feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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

No files matched your search

+36 -13
View File
@@ -230,20 +230,43 @@ func TestInboundBodyURLIsNeverFetched(t *testing.T) {
}
}
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体不走文档内联。
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
// kookPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名)。
var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
// TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界:
// 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但
// 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。
// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。
func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
a, c, _ := newInlineKookAdapter(t, s)
t.Run("字节不是图片: 取回但不内联", func(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t) // 默认正文是文本(Content-Type: text/plain)
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if s.hits.Load() != 0 {
t.Fatalf("图片媒体被文档内联取回了 %d 次,want 0", s.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("图片媒体被写入了字节(应留给视觉链路)")
}
if s.hits.Load() != 1 {
t.Fatalf("图片声明的媒体取回次数 = %d, want 1(图片现在也进内联链路)", s.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("非图片字节被写入了字节(嗅探是唯一裁决权)")
}
})
t.Run("字节是真图: 内联", func(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
s.body = kookPNG
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if !bytes.Equal(c.message(t).Media[0].Data, kookPNG) {
t.Fatalf("Media.Data = %q, want 取回的图片字节", c.message(t).Media[0].Data)
}
})
}
+22 -12
View File
@@ -110,16 +110,20 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error)
return data, nil
}
// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
// 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` 同一条平台口径,只是多了一层白名单、独立的体积上限,以及
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、按类型的体积上限,以及
// **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。
type ilinkFetcher struct {
a *Adapter
@@ -128,21 +132,23 @@ type ilinkFetcher struct {
raw []byte
}
// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
// 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,
Timeout: mediafetch.DefaultTimeout,
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)
@@ -172,6 +178,10 @@ func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) {
// `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 {
@@ -0,0 +1,179 @@
package weixin_ilink
// 本文件锁定微信 iLink 入站**图片**的采集口径(切片 IM-IMG):CDN 引用 → 来源白名单取回
// → 剥 AES-ECB 层 → 写进 `Media.Data`(交回给桥接器内联成 data: URI 进视觉链路)。
//
// 覆盖:正向(含真机形状——`image_item` 既无 file_name 也无 mime)、按条 `aeskey`、
// 来源白名单反证(白名单外的绝对引用 **0 次命中**)、图片口径的体积上限(5 MiB,不是文档的
// 20 MiB)、以及「声明说图片、字节说不是」时**不内联**。
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
// pngBytes 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名,
// 因此样本必须是完整签名 + 载荷,短于签名就会被判成非图片)。
var pngBytes = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
// imageItemRaw 构造一条带图片 item 的原始 iLink 消息。imageItem 就是真机协议里的
// `image_item` 对象(`{"media": {...}}`,可能另有 `aeskey`);**名字与 MIME 本就不存在**,
// 这正是本切片要覆盖的形状。
func imageItemRaw(t *testing.T, imageItem map[string]any) []byte {
t.Helper()
raw, err := json.Marshal(map[string]any{
"client_id": "c-img", "from_user_id": "u-1", "message_type": msgTypeUser,
"item_list": []any{map[string]any{"type": itemImage, "image_item": imageItem}},
})
if err != nil {
t.Fatalf("marshal image item: %v", err)
}
return raw
}
// TestInboundImageIsInlinedAndDecrypted 是正向用例:真机形状的图片 item(相对下载引用、
// 无名字无 MIME)被取回、解密并内联,且字节确实是白名单光栅图。
func TestInboundImageIsInlinedAndDecrypted(t *testing.T) {
t.Parallel()
key := keyOf(0x41)
s := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
a, c := newInlineIlinkAdapter(t, s, key)
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
}))
if m == nil {
t.Fatal("convertMessage 没有解析出消息")
}
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage {
t.Fatalf("媒体不是图片:%+v", msg.Media)
}
if !bytes.Equal(msg.Media[0].Data, pngBytes) {
t.Fatalf("Media.Data = %q, want 解密后的图片字节", msg.Media[0].Data)
}
if s.hits.Load() != 1 {
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
}
// 桥接器构造 data: URI 时用的就是这个裁决入口,这里顺手钉住它对真机字节给得出类型。
if mime, ok := mediafetch.ImageMIMEOf(msg.Media[0].Data); !ok || mime != "image/png" {
t.Fatalf("ImageMIMEOf = %q/%v, want image/png/true", mime, ok)
}
}
// TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded 钉住来源白名单对**图片**同样生效:
// `full_url` 是绝对地址时 `cdnURL` 原样放行,唯一能挡住「对端伪造绝对引用」的就是白名单。
func TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded(t *testing.T) {
t.Parallel()
key := keyOf(0x42)
inside := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
outside := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
a, c := newInlineIlinkAdapter(t, inside, key)
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
"media": map[string]any{"full_url": outside.srv.URL + "/c2c/img.png", "encrypt_query_param": "ENC"},
}))
a.deliver(context.Background(), *m, nil)
if outside.hits.Load() != 0 {
t.Fatalf("白名单外的图片主机被请求了 %d 次,want 0", outside.hits.Load())
}
if inside.hits.Load() != 0 {
t.Fatalf("白名单内的主机不该收到这次请求(引用指向别处):%d 次", inside.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("白名单外的图片被内联了")
}
}
// TestInboundImagePerItemAesKeyIsUsed 钉住图片的**按条密钥**(`image_item.aeskey`,十六进制)
// 优先于 bot_token 兜底:这里刻意让两条路的密钥不同,并断言解出的字节**逐字节等于**明文 ——
// 用错密钥时 AES-ECB 仍会「成功」返回乱码,只断言「非空」会假绿。
func TestInboundImagePerItemAesKeyIsUsed(t *testing.T) {
t.Parallel()
perItem := keyOf(0x43)
fallback := keyOf(0x44)
plain := append([]byte(nil), pngBytes...)
s := newIlinkAssetServer(t, encrypted(t, perItem, plain))
a, c := newInlineIlinkAdapter(t, s, fallback)
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
"aeskey": hex.EncodeToString(perItem),
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
}))
a.deliver(context.Background(), *m, nil)
got := c.message(t).Media[0].Data
if !bytes.Equal(got, plain) {
t.Fatalf("Media.Data = %q, want %q(按条 aeskey 没有被用上)", got, plain)
}
}
// TestInboundImageUsesImageByteCap 钉住图片走**图片口径**的上限(5 MiB),而不是文档的
// 20 MiB:假 CDN 下发的是一份**合法 AES 密文**,解密后是「PNG 签名 + 填充」的 5 MiB+64
// 字节 —— 它介于两个上限之间,且字节本身是**能通过嗅探的合法 PNG 前缀**,因此「没有被内联」
// 只可能来自图片上限(用文档上限就会把它收下)。
func TestInboundImageUsesImageByteCap(t *testing.T) {
t.Parallel()
key := keyOf(0x45)
big := append(append([]byte(nil), pngBytes...), bytes.Repeat([]byte("x"), int(mediafetch.MaxImageBytes)+64-len(pngBytes))...)
s := newIlinkAssetServer(t, encrypted(t, key, big))
a, c := newInlineIlinkAdapter(t, s, key)
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
}))
a.deliver(context.Background(), *m, nil)
if got := len(c.message(t).Media[0].Data); got != 0 {
t.Fatalf("超限的图片被内联了(%d 字节);图片上限应为 %d", got, mediafetch.MaxImageBytes)
}
if s.hits.Load() != 1 {
t.Fatalf("CDN 命中 = %d, want 1(取回发生在上限判定之前)", s.hits.Load())
}
// 反证夹具本身是「合法图片」:去掉上限后会内联的字节,这里必须仍然只因为体积被拒。
if _, ok := mediafetch.ImageMIMEOf(big); !ok {
t.Fatal("夹具要不是合法 PNG,本用例就变成了嗅探拒绝的假绿")
}
}
// TestInboundImageBytesAreSniffedNotTrusted 钉住「字节嗅探是唯一裁决权」在微信这条路上
// 也成立:平台把它标成图片、下发的却是 HTML(AES 密文合法、解密也成功)⇒ **不内联**。
func TestInboundImageBytesAreSniffedNotTrusted(t *testing.T) {
t.Parallel()
key := keyOf(0x46)
s := newIlinkAssetServer(t, encrypted(t, key, []byte("<!DOCTYPE html><html>PROXY</html>")))
a, c := newInlineIlinkAdapter(t, s, key)
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
}))
a.deliver(context.Background(), *m, nil)
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("非图片字节被当成图片内联了")
}
if s.hits.Load() != 1 {
t.Fatalf("CDN 命中 = %d, want 1(必须看字节才知道它不是图片)", s.hits.Load())
}
}
// TestMaxBytesForImageIsNotDocumentCap 钉住渠道侧引用的那一份「按类型分档」映射:图片 5 MiB、
// 文档类 20 MiB,两者不允许撞在一起(一个数只能有一个答案)。
func TestMaxBytesForImageIsNotDocumentCap(t *testing.T) {
t.Parallel()
if got := mediafetch.MaxBytesFor(adapter.Media{Kind: adapter.MediaImage}); got != mediafetch.MaxImageBytes {
t.Fatalf("MaxBytesFor(image) = %d, want %d", got, mediafetch.MaxImageBytes)
}
if mediafetch.MaxImageBytes >= 20<<20 {
t.Fatalf("图片上限 %d 已经不小于文档上限,两者解耦失效", mediafetch.MaxImageBytes)
}
}
@@ -173,7 +173,8 @@ func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) {
// TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey`
// (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有
// 走对分支才解得出正文。图片不进内联链路(mediafetch 只管文件类),所以直接驱动 Fetcher。
// 走对分支才解得出正文。本用例钉的是**取回层**的密钥选择(图片现在也走内联链路,但这里的
// 载荷不是可嗅探的真图字节,走内联会被字节裁决挡下),所以直接驱动 Fetcher。
func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) {
t.Parallel()
right := keyOf(0x42)