- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix) + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL - 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节 (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url; weixin cdnURL + AES-ECB 解密) - 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage - channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立 - 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图) - 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
270 lines
8.7 KiB
Go
270 lines
8.7 KiB
Go
package weixin_ilink
|
||
|
||
// 本文件锁定微信 iLink 的**入站附件字节内联**(切片 IM-1):CDN 引用 → 白名单取回 →
|
||
// 剥 AES-ECB 层 → 内联。覆盖正向、来源不上白名单不下载、非白名单格式不下载、超限、
|
||
// 解密失败诚实放弃,以及红线(正文里的 URL 绝不被取回)。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/aes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
)
|
||
|
||
// ilinkAssetServer 是假的 CDN:`/c2c/...` 计命中次数,并记录请求头(用于断言凭据与
|
||
// 标识头确实随取回发出)。
|
||
type ilinkAssetServer struct {
|
||
srv *httptest.Server
|
||
hits atomic.Int32
|
||
body []byte
|
||
code int
|
||
auth atomic.Value
|
||
}
|
||
|
||
func newIlinkAssetServer(t *testing.T, body []byte) *ilinkAssetServer {
|
||
t.Helper()
|
||
s := &ilinkAssetServer{body: body}
|
||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
s.hits.Add(1)
|
||
s.auth.Store(r.Header.Get("Authorization"))
|
||
if s.code != 0 {
|
||
w.WriteHeader(s.code)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
_, _ = w.Write(s.body)
|
||
}))
|
||
t.Cleanup(s.srv.Close)
|
||
return s
|
||
}
|
||
|
||
// fileItemRaw 构造一条带文件 item 的原始 iLink 消息。
|
||
func fileItemRaw(ref, name string) []byte {
|
||
raw, err := json.Marshal(map[string]any{
|
||
"client_id": "c-1", "from_user_id": "u-1", "message_type": msgTypeUser,
|
||
"item_list": []any{
|
||
map[string]any{"type": itemText, "text_item": map[string]any{"text": "看看这个文件"}},
|
||
map[string]any{"type": itemFile, "file_item": map[string]any{
|
||
"file_name": name, "url": ref, "size": 1024, "mime_type": "text/plain",
|
||
}},
|
||
},
|
||
})
|
||
if err != nil {
|
||
panic(err.Error())
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// textItemRaw 构造一条只有文本 item 的原始消息(正文里带 URL)。
|
||
func textItemRaw(text string) []byte {
|
||
raw, err := json.Marshal(map[string]any{
|
||
"client_id": "c-2", "from_user_id": "u-1", "message_type": msgTypeUser,
|
||
"item_list": []any{map[string]any{"type": itemText, "text_item": map[string]any{"text": text}}},
|
||
})
|
||
if err != nil {
|
||
panic(err.Error())
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// ilinkInlineCapture 捕获 deliver 投递出来的消息。
|
||
type ilinkInlineCapture struct {
|
||
mu sync.Mutex
|
||
last adapter.UnifiedMessage
|
||
got int
|
||
}
|
||
|
||
func (c *ilinkInlineCapture) fn(_ context.Context, msg adapter.UnifiedMessage) {
|
||
c.mu.Lock()
|
||
c.last = msg
|
||
c.got++
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
func (c *ilinkInlineCapture) message(t *testing.T) adapter.UnifiedMessage {
|
||
t.Helper()
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.got == 0 {
|
||
t.Fatal("入站消息没有被投递出来")
|
||
}
|
||
return c.last
|
||
}
|
||
|
||
// newInlineIlinkAdapter 装配一个「CDN 根指向假服务端」的适配器与捕获器。
|
||
func newInlineIlinkAdapter(t *testing.T, s *ilinkAssetServer, key []byte) (*Adapter, *ilinkInlineCapture) {
|
||
t.Helper()
|
||
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: encodeToken(key)}
|
||
a := newAdapter(t, cfg, s.srv)
|
||
c := &ilinkInlineCapture{}
|
||
a.SetMessageHandler(c.fn)
|
||
return a, c
|
||
}
|
||
|
||
// keyOf 返回确定性的 AES 密钥(每个用例自己给种子)。
|
||
func keyOf(seed byte) []byte { return bytes.Repeat([]byte{seed}, aes.BlockSize) }
|
||
|
||
// encrypted 用 key 加密 plain,供假 CDN 下发。
|
||
func encrypted(t *testing.T, key, plain []byte) []byte {
|
||
t.Helper()
|
||
out, err := aesECBEncrypt(key, plain)
|
||
if err != nil {
|
||
t.Fatalf("aesECBEncrypt: %v", err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestInboundFileIsInlinedAndDecrypted 正向:CDN 引用 → 白名单取回 → 解密 → 内联。
|
||
func TestInboundFileIsInlinedAndDecrypted(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x31)
|
||
plain := []byte("微信文件正文\n")
|
||
s := newIlinkAssetServer(t, encrypted(t, key, plain))
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw("media/1.txt", "report.txt"))
|
||
if m == nil {
|
||
t.Fatal("convertMessage 没有解析出消息")
|
||
}
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
msg := c.message(t)
|
||
if len(msg.Media) != 1 || string(msg.Media[0].Data) != string(plain) {
|
||
t.Fatalf("Media.Data = %q, want 解密后的正文", msg.Media[0].Data)
|
||
}
|
||
if s.hits.Load() != 1 {
|
||
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
|
||
}
|
||
if got, _ := s.auth.Load().(string); !strings.HasPrefix(got, "Bearer ") {
|
||
t.Fatalf("CDN 取回没有带 bearer 凭据:%q", got)
|
||
}
|
||
}
|
||
|
||
// TestInboundAbsoluteRefOffAllowlistIsNotDownloaded 钉住来源白名单:`cdnURL` 对**绝对
|
||
// URL 原样放行**,因此对端伪造一个指向别处的主机时,唯一能挡住它的就是白名单
|
||
// (断言那台主机 0 次命中)。
|
||
func TestInboundAbsoluteRefOffAllowlistIsNotDownloaded(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x32)
|
||
inside := newIlinkAssetServer(t, encrypted(t, key, []byte("ok\n")))
|
||
outside := newIlinkAssetServer(t, encrypted(t, key, []byte("evil\n")))
|
||
a, c := newInlineIlinkAdapter(t, inside, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw(outside.srv.URL+"/c2c/evil.txt", "report.txt"))
|
||
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("白名单外的附件被内联了")
|
||
}
|
||
}
|
||
|
||
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。
|
||
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x33)
|
||
s := newIlinkAssetServer(t, encrypted(t, key, []byte("x")))
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw("media/1.exe", "payload.exe"))
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
if s.hits.Load() != 0 {
|
||
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.hits.Load())
|
||
}
|
||
if len(c.message(t).Media[0].Data) != 0 {
|
||
t.Fatal("非白名单格式被内联了")
|
||
}
|
||
}
|
||
|
||
// TestInboundOversizeBodyIsCutWhileReading 钉住边读边限。这里刻意让假 CDN 下发一份
|
||
// **合法 AES 密文**的超大附件:解密一定能成功,因此「Data 为空」只可能来自体积上限
|
||
// (若用随机字节,解密失败会替上限背锅,用例就变成了假绿)。
|
||
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x34)
|
||
big := bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
|
||
s := newIlinkAssetServer(t, encrypted(t, key, big))
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw("media/big.txt", "big.txt"))
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
if len(c.message(t).Media[0].Data) != 0 {
|
||
t.Fatal("超量响应被内联了")
|
||
}
|
||
}
|
||
|
||
// TestInboundUnreadableCiphertextIsHonest 钉住「解码失败 ⇒ 诚实放弃」:拿到的密文
|
||
// 不是合法的 AES 分组序列时该附件留空,消息照收。
|
||
func TestInboundUnreadableCiphertextIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x35)
|
||
s := newIlinkAssetServer(t, []byte("not-a-cipher-block"))
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw("media/bad.txt", "bad.txt"))
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
msg := c.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatal("解密失败时仍写入了字节")
|
||
}
|
||
if msg.SenderID != "u-1" {
|
||
t.Fatalf("失败路径把消息弄丢了:%+v", msg.SenderID)
|
||
}
|
||
}
|
||
|
||
// TestInboundBodyURLIsNeverFetched 是红线用例:文本 item(正文)里带任意 URL 时
|
||
// **绝不**对它发起请求(断言 0 次命中)。
|
||
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x36)
|
||
s := newIlinkAssetServer(t, encrypted(t, key, []byte("secret\n")))
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(textItemRaw("读一下 " + s.srv.URL + "/c2c/secret.txt"))
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
if s.hits.Load() != 0 {
|
||
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load())
|
||
}
|
||
if c.got == 0 {
|
||
t.Fatal("文本消息没有被投递")
|
||
}
|
||
}
|
||
|
||
// TestInboundFetchFailureIsHonest 钉住失败路径:CDN 5xx ⇒ 放弃内联,消息照收。
|
||
func TestInboundFetchFailureIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
key := keyOf(0x37)
|
||
s := newIlinkAssetServer(t, []byte("ignored"))
|
||
s.code = http.StatusBadGateway
|
||
a, c := newInlineIlinkAdapter(t, s, key)
|
||
|
||
_, m := a.convertMessage(fileItemRaw("media/1.txt", "report.txt"))
|
||
a.deliver(context.Background(), *m, nil)
|
||
|
||
msg := c.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatal("失败时仍写入了字节")
|
||
}
|
||
if msg.ChannelID != "u-1" {
|
||
t.Fatalf("失败路径把消息弄丢了:%+v", msg.ChannelID)
|
||
}
|
||
}
|