feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 超量用例)
This commit is contained in:
AskaEth committed 2026-10-04 22:13:17 +08:00
1 parent 46caa724f2
commit 3be70f8232
22 files changed
+2601 -19

No files matched your search

+3
View File
@@ -213,6 +213,9 @@ func (a *Adapter) ProcessUpdate(ctx context.Context, raw []byte) (int, error) {
if err := msg.Validate(); err != nil {
return 0, fmt.Errorf("telegram: validate webhook update: %w", err)
}
// 附件字节在**投递之前**内联:webhook 与长轮询两条入站路径共用同一口径
// (切片 IM-1)——下游因此拿到的永远是一份已经带字节(或明确没有字节)的消息。
a.inlineInboundMedia(ctx, &msg)
a.mu.RLock()
handler := a.handler
a.mu.RUnlock()
+3
View File
@@ -14,4 +14,7 @@ var (
// ErrNoChatTarget is returned when an outbound message carries no usable
// conversation/group identifier.
ErrNoChatTarget = errors.New("telegram: message has no target chat")
// ErrNoFilePath is returned when getFile yields no usable file_path (empty
// or a path shape we refuse to splice into a download URL).
ErrNoFilePath = errors.New("telegram: file path unavailable")
)
+107
View File
@@ -0,0 +1,107 @@
package telegram
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类
// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个
// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。
//
// 三条边界:
// - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机;
// 平台返回的路径还要过 `sanitizeFilePath`(拒绝对协议前缀、`..`、查询串);
// - 来源白名单 = 已配置的 API 根(默认 `https://api.telegram.org`):白名单之外的
// 主机**一个字节都不发出去**;
// - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用),
// 这里只负责「把平台引用换成一个平台资产地址」。
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
// getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。
const getFileMethod = "getFile"
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。
// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
// 一行诚实提示(design/24 §4.4)。
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a})
}
// telegramFetcher 实现 mediafetch.Fetcher:Telegram 给的是 `file_id`,因此它必须
// 先问平台「这个 id 对应哪个路径」,再下载。
type telegramFetcher struct{ a *Adapter }
// Fetch 取回一份 Telegram 附件的字节。体积上限与超时都不在这里写数:前者由
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
// `mediafetch.DefaultTimeout`。
func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
filePath, err := f.a.filePath(ctx, m.URL)
if err != nil {
return nil, err
}
return mediafetch.Fetch(ctx, mediafetch.Request{
URL: f.a.fileURL(filePath),
Allow: f.a.assetAllowlist(),
Client: f.a.client,
Timeout: mediafetch.DefaultTimeout,
})
}
// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址
// 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把
// 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
origin, err := mediafetch.OriginOf(a.baseURL)
if err != nil {
return mediafetch.NewAllowlist()
}
return mediafetch.NewAllowlist(origin)
}
// fileURL 把平台给出的相对路径拼成下载地址。token 在路径里(与 endpoint 同口径),
// 绝不进查询串;这个地址**只用于请求**,绝不进日志(mediafetch 的错误只带来源)。
func (a *Adapter) fileURL(filePath string) string {
return a.baseURL + "/file/bot" + a.cfg.BotToken + "/" + strings.TrimLeft(filePath, "/")
}
// filePath 调 getFile 把平台文件 id 换成平台自己返回的相对路径,并做形态校验。
func (a *Adapter) filePath(ctx context.Context, fileID string) (string, error) {
if strings.TrimSpace(fileID) == "" {
return "", ErrNoFilePath
}
out, err := a.doJSON(ctx, getFileMethod, map[string]any{"file_id": fileID})
if err != nil {
return "", err
}
var res struct {
FilePath string `json:"file_path"`
}
if err := json.Unmarshal(out.Result, &res); err != nil {
return "", fmt.Errorf("telegram: getFile result: %w", err)
}
return sanitizeFilePath(res.FilePath)
}
// sanitizeFilePath 校验平台返回的文件路径:非空、不含协议前缀 / `..` / 查询串 /
// 控制字符。校验失败时返回 ErrNoFilePath —— 绝不把可疑路径拼进下载地址。
func sanitizeFilePath(filePath string) (string, error) {
path := strings.TrimSpace(filePath)
if path == "" {
return "", ErrNoFilePath
}
for _, r := range path {
if r < ' ' || r == 0x7f {
return "", fmt.Errorf("%w: 平台返回的路径含控制字符", ErrNoFilePath)
}
}
if strings.Contains(path, "://") || strings.Contains(path, "..") ||
strings.ContainsAny(path, "\\?#") {
return "", fmt.Errorf("%w: 平台返回的路径形态非法", ErrNoFilePath)
}
return path, nil
}
+316
View File
@@ -0,0 +1,316 @@
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)
}
}
+1
View File
@@ -71,6 +71,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
a.log.Warn("skip invalid message", "err", err)
continue
}
a.inlineInboundMedia(pollCtx, &msg)
emit(msg)
}
a.mu.Lock()