Files
Cyrene_v2/plugins/channels/weixin_ilink/media_image_test.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

180 lines
7.8 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
// 本文件锁定微信 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)
}
}