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

267 lines
10 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 入站附件的**密钥来源与真实字段形态**(2026-10-04 真机缺陷)。
//
// 真机形态(官方 @tencent-weixin/openclaw-weixin 2.4.9 的 src/api/types.ts 与
// src/media/media-download.ts):引用与密钥都在 `<kind>_item.media` 里 ——
// `encrypt_query_param`(或 `full_url`)给地址、`aes_key` 给密钥(图片优先
// `image_item.aeskey`);文件名在 `file_name`,体积在**字符串** `len`。
//
// 旧实现只认平铺的 `file_item.url`,且把**整个 bot_token** 当 base64 去解(自标 ⚠️ 的
// design/08 §3.3 口径)——真实 token 形如 `<12 位>@im.bot:<38 位十六进制>`,在第 12 个
// 字符 `@` 处必失败。下面的夹具因此刻意用「同形但不是真凭据」的 token,让「附件能不能
// 读出来」只取决于按条 aes_key 是否被用上。
import (
"bytes"
"context"
"crypto/aes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// realCdnServer 是假 iLink CDN:记录命中次数与服务端**解码后**的 encrypt_query_param,
// 并按需下发字节。它比共享夹具多记一个查询参数,用来钉住引用必须转义。
type realCdnServer struct {
srv *httptest.Server
hits atomic.Int32
query atomic.Value
body []byte
}
func newRealCdnServer(t *testing.T, body []byte) *realCdnServer {
t.Helper()
s := &realCdnServer{body: body}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.hits.Add(1)
s.query.Store(r.URL.Query().Get("encrypted_query_param"))
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(s.body)
}))
t.Cleanup(s.srv.Close)
return s
}
// seenQuery 返回服务端解码后的 encrypt_query_param。
func (s *realCdnServer) seenQuery() string {
v, _ := s.query.Load().(string)
return v
}
// realShapeBotToken 返回一个**与真机同形**的 bot_token:`<12 位>@im.bot:<38 位十六进制>`。
// 它不是任何真实凭据,只是形状 —— 但足以让「整体 base64 解码 token」这条路必失败。
func realShapeBotToken() string {
return "0123456789ab@im.bot:" + strings.Repeat("0f", 19)
}
// realFileItemRaw 构造一条真机形态的文件消息。
func realFileItemRaw(encParam, aesKeyB64, name, size string) []byte {
item := map[string]any{
"type": itemFile,
"file_item": map[string]any{
"media": map[string]any{"encrypt_query_param": encParam, "aes_key": aesKeyB64},
"file_name": name,
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"len": size,
},
}
return wrapItem(item)
}
// realImageItemRaw 构造一条真机形态的图片消息:十六进制密钥在 `image_item.aeskey`,
// 这里让 `media.aes_key` 带一个**错误**密钥(base64 编码),只有走对优先分支才解得开。
func realImageItemRaw(encParam, aeskeyHex, wrongKeyB64 string) []byte {
item := map[string]any{
"type": itemImage,
"image_item": map[string]any{
"media": map[string]any{"encrypt_query_param": encParam, "aes_key": wrongKeyB64},
"aeskey": aeskeyHex,
},
}
return wrapItem(item)
}
// wrapItem 把单个 item 包成一条入站用户消息。
func wrapItem(item map[string]any) []byte {
raw, err := json.Marshal(map[string]any{
"client_id": "c-1", "from_user_id": "u-1", "message_type": msgTypeUser,
"item_list": []any{item},
})
if err != nil {
panic(err.Error())
}
return raw
}
// newRealCdnAdapter 装配一个「CDN 根指向假服务端、bot_token 为真机形态」的适配器与捕获器。
func newRealCdnAdapter(t *testing.T, s *realCdnServer) (*Adapter, *ilinkInlineCapture) {
t.Helper()
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()}
a := newAdapter(t, cfg, s.srv)
c := &ilinkInlineCapture{}
a.SetMessageHandler(c.fn)
return a, c
}
// TestInboundRealFileItemIsDecryptedWithPerItemKey 是 **Bug A 的回归用例**:真机形态的
// 文件 item(引用在 media.encrypt_query_param、密钥在 media.aes_key)+ 真机形态的
// bot_token(不是 base64)。修好之前,取回在 deriveInboundKey 上就失败,字节为空。
func TestInboundRealFileItemIsDecryptedWithPerItemKey(t *testing.T) {
t.Parallel()
key := keyOf(0x41)
plain := []byte("真机形态的文件正文\n")
// 查询参数里带 `+`/`/`/`=`:不转义的话服务端会把 `+` 解成空格,签名参数就废了。
encParam := "ab+cd/ef="
s := newRealCdnServer(t, encrypted(t, key, plain))
a, c := newRealCdnAdapter(t, s)
_, m := a.convertMessage(realFileItemRaw(encParam, base64.StdEncoding.EncodeToString(key), "1.txt", "24"))
if m == nil {
t.Fatal("convertMessage 没有解析出消息")
}
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if len(msg.Media) != 1 {
t.Fatalf("Media = %d 条, want 1", len(msg.Media))
}
got := msg.Media[0]
if !bytes.Equal(got.Data, plain) {
t.Fatalf("Media.Data = %q, want %q", got.Data, plain)
}
if got.Name != "1.txt" {
t.Fatalf("Name = %q, want 1.txt", got.Name)
}
if got.Size != 24 {
t.Fatalf("Size = %d, want 24(file_item.len 是字符串,必须能解析)", got.Size)
}
if !strings.Contains(got.URL, "/download?encrypted_query_param=") {
t.Fatalf("URL = %q, want 归一后的 CDN 下载地址", got.URL)
}
if s.hits.Load() != 1 {
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
}
if s.seenQuery() != encParam {
t.Fatalf("服务端收到的 encrypt_query_param = %q, want %q(引用必须转义)", s.seenQuery(), encParam)
}
}
// TestInboundRealFileItemAcceptsBase64HexAesKey 覆盖官方 parseAesKey 的第二种编码:
// 文件/语音/视频的 aes_key 是 **base64(32 位十六进制串)**,要先 base64 再解一层十六进制。
func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) {
t.Parallel()
key := keyOf(0x44)
plain := []byte("十六进制密钥包装形态\n")
s := newRealCdnServer(t, encrypted(t, key, plain))
a, c := newRealCdnAdapter(t, s)
wrapped := base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(key)))
_, m := a.convertMessage(realFileItemRaw("only-param", wrapped, "report.txt", "1024"))
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if !bytes.Equal(msg.Media[0].Data, plain) {
t.Fatalf("Media.Data = %q, want %q", msg.Media[0].Data, plain)
}
}
// TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey`
// (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有
// 走对分支才解得出正文。本用例钉的是**取回层**的密钥选择(图片现在也走内联链路,但这里的
// 载荷不是可嗅探的真图字节,走内联会被字节裁决挡下),所以直接驱动 Fetcher。
func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) {
t.Parallel()
right := keyOf(0x42)
wrong := keyOf(0x99)
plain := []byte("jpeg-bytes\n")
s := newRealCdnServer(t, encrypted(t, right, plain))
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()}
a := newAdapter(t, cfg, s.srv)
raw := realImageItemRaw("img+param=", hex.EncodeToString(right), base64.StdEncoding.EncodeToString(wrong))
_, m := a.convertMessage(raw)
if m == nil || len(m.Media) != 1 {
t.Fatalf("convertMessage 没有解析出图片媒体:%+v", m)
}
got, err := ilinkFetcher{a: a, raw: raw}.Fetch(context.Background(), m.Media[0])
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("解出的字节 = %q, want %q", got, plain)
}
}
// TestInboundFileItemWithoutAesKeyIsHonest 钉住诚实失败:附件没带密钥时,宁可报错让
// 上游给模型一行「读不到」,也绝不用 bot_token 兜底去解出一堆乱码 —— 而且连请求都不该
// 发出去(密钥都不齐,取回来也只会浪费一次传输)。
func TestInboundFileItemWithoutAesKeyIsHonest(t *testing.T) {
t.Parallel()
key := keyOf(0x45)
s := newRealCdnServer(t, encrypted(t, key, []byte("unreachable")))
a, c := newRealCdnAdapter(t, s)
_, m := a.convertMessage(realFileItemRaw("no-key-param", "", "1.txt", "3"))
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("没有密钥却写入了字节:%q", msg.Media[0].Data)
}
if s.hits.Load() != 0 {
t.Fatalf("密钥不齐仍发起了 %d 次请求, want 0", s.hits.Load())
}
if msg.SenderID != "u-1" {
t.Fatalf("失败路径把消息弄丢了:%q", msg.SenderID)
}
}
// TestDeriveInboundKey_AcceptsRawAndURLSafeBase64 钉住兜底路径的编码容忍度:凭据由
// 第三方下发,无 padding 与 URL-safe(`-`/`_`)两种形态都要认 —— 先猜一种再报
// 「illegal base64」正是真机踩过的坑。
func TestDeriveInboundKey_AcceptsRawAndURLSafeBase64(t *testing.T) {
t.Parallel()
key := keyOf(0xFF)
payload := append([]byte("prefix!"), key...)
std := base64.StdEncoding.EncodeToString(payload)
urlSafe := strings.NewReplacer("+", "-", "/", "_", "=", "").Replace(std)
if !strings.ContainsAny(urlSafe, "-_") || urlSafe == std {
t.Fatalf("夹具没有造出 URL-safe 无 padding 形态:%q", std)
}
for name, token := range map[string]string{
"url-safe raw": urlSafe,
"standard raw": strings.TrimRight(std, "="),
"standard std": std,
} {
got, err := deriveInboundKey(token)
if err != nil {
t.Fatalf("%s: deriveInboundKey: %v", name, err)
}
if !bytes.Equal(got, key) {
t.Fatalf("%s: key = %x, want %x", name, got, key)
}
}
}
// TestParseMediaAesKey_RejectsWrongLength 钉住密钥长度校验:长度不对时诚实报错,
// 而不是拿被截断/补零的东西去解密。
func TestParseMediaAesKey_RejectsWrongLength(t *testing.T) {
t.Parallel()
if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 8))); err == nil {
t.Fatal("8 字节的 aes_key 被接受了")
}
if _, err := parseMediaAesKey(""); err == nil {
t.Fatal("空的 aes_key 被接受了")
}
if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, aes.BlockSize))); err != nil {
t.Fatalf("16 字节原始密钥被拒:%v", err)
}
}