Files
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 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 超量用例)
2026-10-04 22:13:17 +08:00

270 lines
8.7 KiB
Go
Raw Permalink 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 的**入站附件字节内联**(切片 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)
}
}