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("PROXY"))) 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) } }