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