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