feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- 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:
1 parent
5de708a560
commit
6a545badbd
8 files changed
+1004
-37
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user