- 新增 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 超量用例)
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package telegram
|
||
|
||
// 本文件锁定 Telegram 的**入站附件字节内联**(切片 IM-1):
|
||
//
|
||
// - 正向:平台给 file_id ⇒ getFile 换路径 ⇒ 下载字节内联(假 HTTP 服务端,不碰真平台);
|
||
// - 超限:声明超限不发请求;未声明而服务端超量 ⇒ 边读边限、放弃内联、日志与提示都不静默;
|
||
// - 非白名单格式 ⇒ **不发起下载**(连 getFile 都不调,断言 0 次命中);
|
||
// - 超时/失败 ⇒ 诚实放弃(消息照收);
|
||
// - **红线**:正文里的任意 URL 绝不被取回(断言服务端 0 次命中)。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"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/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
)
|
||
|
||
// tgAssetServer 是假的 Bot API + 文件服务端:它区分 getFile 调用与真正的文件下载,
|
||
// 使「0 次命中」可以精确断言在**文件下载**这件事上。
|
||
type tgAssetServer struct {
|
||
srv *httptest.Server
|
||
getFile atomic.Int32
|
||
fileHits atomic.Int32
|
||
allHits atomic.Int32
|
||
fileBody []byte
|
||
fileCode int
|
||
fileDelay bool
|
||
}
|
||
|
||
func newTGAssetServer(t *testing.T) *tgAssetServer {
|
||
t.Helper()
|
||
s := &tgAssetServer{fileBody: []byte("附件的正文\n")}
|
||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
s.allHits.Add(1)
|
||
switch {
|
||
case strings.HasSuffix(r.URL.Path, "/getFile"):
|
||
s.getFile.Add(1)
|
||
okResponse(w, map[string]any{"file_id": "doc-id", "file_path": "documents/file_1.txt"})
|
||
case strings.Contains(r.URL.Path, "/file/bot"):
|
||
s.fileHits.Add(1)
|
||
if s.fileCode != 0 {
|
||
w.WriteHeader(s.fileCode)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain")
|
||
_, _ = w.Write(s.fileBody)
|
||
default:
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
t.Cleanup(s.srv.Close)
|
||
return s
|
||
}
|
||
|
||
// captureHandler 捕获投递给上层的最后一条消息。
|
||
type captureHandler struct {
|
||
mu sync.Mutex
|
||
last adapter.UnifiedMessage
|
||
got int
|
||
}
|
||
|
||
func (h *captureHandler) fn(_ context.Context, msg adapter.UnifiedMessage) {
|
||
h.mu.Lock()
|
||
h.last = msg
|
||
h.got++
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
func (h *captureHandler) message(t *testing.T) adapter.UnifiedMessage {
|
||
t.Helper()
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if h.got == 0 {
|
||
t.Fatal("附件消息没有被投递给上层")
|
||
}
|
||
return h.last
|
||
}
|
||
|
||
// newInlineAdapter 装配一个接到假服务端的适配器 + 捕获器 + 日志缓冲。
|
||
func newInlineAdapter(t *testing.T, s *tgAssetServer) (*Adapter, *captureHandler, *bytes.Buffer) {
|
||
t.Helper()
|
||
logs := &bytes.Buffer{}
|
||
a := New(
|
||
Config{BotToken: "123456:TESTTOKEN"},
|
||
WithBaseURL(s.srv.URL),
|
||
WithHTTPClient(s.srv.Client()),
|
||
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/telegram")),
|
||
)
|
||
h := &captureHandler{}
|
||
a.SetMessageHandler(h.fn)
|
||
return a, h, logs
|
||
}
|
||
|
||
// documentUpdate 构造一条 Telegram 文档消息的原始 update。
|
||
func documentUpdate(name string, size int64, text string) []byte {
|
||
body := map[string]any{
|
||
"update_id": 1,
|
||
"message": map[string]any{
|
||
"message_id": 42,
|
||
"date": 1700000000,
|
||
"chat": map[string]any{"id": 7, "type": "private"},
|
||
"from": map[string]any{"id": 9, "first_name": "主人"},
|
||
"text": text,
|
||
"document": map[string]any{
|
||
"file_id": "doc-id", "file_name": name, "file_size": size, "mime_type": "text/plain",
|
||
},
|
||
},
|
||
}
|
||
raw, err := json.Marshal(body)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("marshal update: %v", err))
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// TestInboundDocumentIsInlined 是正向用例:file_id ⇒ getFile ⇒ 下载 ⇒ Data 被填、
|
||
// 且消息照常投递(Data 之外的字段一字不动)。
|
||
func TestInboundDocumentIsInlined(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
a, h, _ := newInlineAdapter(t, s)
|
||
|
||
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "看看这个")); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "附件的正文\n" {
|
||
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
|
||
}
|
||
if msg.Media[0].Name != "report.txt" || msg.Media[0].URL != "doc-id" {
|
||
t.Fatalf("内联改动了媒体元数据:%+v", msg.Media[0])
|
||
}
|
||
if s.getFile.Load() != 1 || s.fileHits.Load() != 1 {
|
||
t.Fatalf("getFile=%d fileHits=%d, want 1/1", s.getFile.Load(), s.fileHits.Load())
|
||
}
|
||
}
|
||
|
||
// TestInboundDeclaredOversizeIsNotDownloaded 钉住「平台声明超限 ⇒ 连请求都不发」。
|
||
func TestInboundDeclaredOversizeIsNotDownloaded(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
a, h, logs := newInlineAdapter(t, s)
|
||
|
||
_, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", documents.DefaultMaxBytes+1, "超大文件"))
|
||
if err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("超限附件被内联了(%d 字节)", len(msg.Media[0].Data))
|
||
}
|
||
if s.getFile.Load() != 0 || s.allHits.Load() != 0 {
|
||
t.Fatalf("声明超限仍发起了请求:getFile=%d all=%d", s.getFile.Load(), s.allHits.Load())
|
||
}
|
||
if !strings.Contains(logs.String(), "declared size over limit") {
|
||
t.Fatalf("超限没有留下带来源的日志:%q", logs.String())
|
||
}
|
||
}
|
||
|
||
// TestInboundOversizeBodyIsCutWhileReading 钉住「边读边限」:平台声明的体积不可信
|
||
// (这里声明为 1 字节),服务端实际吐出超限字节时也要放弃。
|
||
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
s.fileBody = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
|
||
a, h, logs := newInlineAdapter(t, s)
|
||
|
||
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", 1, "超量")); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("超量响应被内联了(%d 字节)", len(msg.Media[0].Data))
|
||
}
|
||
if s.fileHits.Load() != 1 {
|
||
t.Fatalf("fileHits = %d, want 1(这次请求本来就该发)", s.fileHits.Load())
|
||
}
|
||
if !strings.Contains(logs.String(), "inline failed") || !strings.Contains(logs.String(), "体积上限") {
|
||
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
|
||
}
|
||
}
|
||
|
||
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式**不发起下载**
|
||
// (连 getFile 都不调),消息照收。
|
||
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
a, h, _ := newInlineAdapter(t, s)
|
||
|
||
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("payload.exe", 1024, "跑一下")); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("非白名单格式被内联了")
|
||
}
|
||
if s.allHits.Load() != 0 {
|
||
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.allHits.Load())
|
||
}
|
||
}
|
||
|
||
// TestInboundFetchFailureIsHonest 钉住失败路径:服务端 5xx / 连接失败 ⇒ 放弃内联、
|
||
// 消息照收、日志带来源(且不含 URL 原串与 token)。
|
||
func TestInboundFetchFailureIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
s.fileCode = http.StatusInternalServerError
|
||
a, h, logs := newInlineAdapter(t, s)
|
||
|
||
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "读一下")); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("失败时仍写入了字节")
|
||
}
|
||
if !strings.Contains(msg.Content, "读一下") {
|
||
t.Fatalf("失败路径改动了正文:%q", msg.Content)
|
||
}
|
||
log := logs.String()
|
||
if !strings.Contains(log, "inline failed") {
|
||
t.Fatalf("失败没有留下带来源的日志:%q", log)
|
||
}
|
||
if strings.Contains(log, "TESTTOKEN") || strings.Contains(log, "TESTTOKEN") {
|
||
t.Fatalf("日志泄露了 token:%q", log)
|
||
}
|
||
}
|
||
|
||
// TestInboundFetchHonorsContextCancel 钉住超时接线:父 ctx 取消时下载立刻放弃
|
||
// (不必等 30s 的具名超时),消息照收。
|
||
func TestInboundFetchHonorsContextCancel(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
a, h, logs := newInlineAdapter(t, s)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
if _, err := a.ProcessUpdate(ctx, documentUpdate("report.txt", 12, "读一下")); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
msg := h.message(t)
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("ctx 已取消却仍内联了字节")
|
||
}
|
||
if !strings.Contains(logs.String(), "inline failed") {
|
||
t.Fatalf("ctx 取消没有留下带来源的日志:%q", logs.String())
|
||
}
|
||
}
|
||
|
||
// TestInboundBodyURLIsNeverFetched 是本切片的红线用例:正文里带任意 URL 的消息
|
||
// **绝不**对那个 URL 发起请求(断言服务端 0 次命中)。
|
||
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTGAssetServer(t)
|
||
a, _, _ := newInlineAdapter(t, s)
|
||
// 正文直接点名一个「附件地址」形态的 URL(服务端确实能应答它)。
|
||
body := "帮我读一下 " + s.srv.URL + "/file/bot123456:TESTTOKEN/documents/file_1.txt 这份文件"
|
||
|
||
if _, err := a.ProcessUpdate(context.Background(), bodyOnlyUpdate(body)); err != nil {
|
||
t.Fatalf("ProcessUpdate: %v", err)
|
||
}
|
||
|
||
if s.allHits.Load() != 0 {
|
||
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.allHits.Load())
|
||
}
|
||
}
|
||
|
||
// bodyOnlyUpdate 构造一条**只有正文、没有任何媒体**的消息。
|
||
func bodyOnlyUpdate(text string) []byte {
|
||
body := map[string]any{
|
||
"update_id": 2,
|
||
"message": map[string]any{
|
||
"message_id": 43,
|
||
"date": 1700000001,
|
||
"chat": map[string]any{"id": 7, "type": "private"},
|
||
"from": map[string]any{"id": 9, "first_name": "主人"},
|
||
"text": text,
|
||
},
|
||
}
|
||
raw, err := json.Marshal(body)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("marshal update: %v", err))
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// TestFilePathSanitization 钉住平台返回路径的形态校验:可疑路径一律拒绝,绝不拼进
|
||
// 下载地址。
|
||
func TestFilePathSanitization(t *testing.T) {
|
||
t.Parallel()
|
||
for _, bad := range []string{"", " ", "https://evil.example.com/x", "../../etc/passwd", "a\\b.txt", "x.txt?q=1", "x\n.txt"} {
|
||
if _, err := sanitizeFilePath(bad); err == nil {
|
||
t.Fatalf("sanitizeFilePath(%q) = nil, want error", bad)
|
||
}
|
||
}
|
||
if got, err := sanitizeFilePath("documents/file_1.txt"); err != nil || got != "documents/file_1.txt" {
|
||
t.Fatalf("sanitizeFilePath(合法路径) = %q, %v", got, err)
|
||
}
|
||
}
|