feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)
This commit is contained in:
1 parent
0cbc9ff886
commit
b73a1276c6
18 files changed
+1224
-121
No files matched your search
@@ -1,8 +1,9 @@
|
||||
package telegram
|
||||
|
||||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类
|
||||
// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个
|
||||
// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。
|
||||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1 / IM-IMG):Bot API 上报的
|
||||
// **文件类与图片类**媒体都只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id
|
||||
// 换成一个**平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载(两步同一条
|
||||
// 路、同一份来源白名单;图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限)。
|
||||
//
|
||||
// 三条边界:
|
||||
// - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机;
|
||||
@@ -25,8 +26,8 @@ import (
|
||||
// getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。
|
||||
const getFileMethod = "getFile"
|
||||
|
||||
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。
|
||||
// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
|
||||
// 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})
|
||||
@@ -52,7 +53,8 @@ func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, er
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址
|
||||
// assetAllowlist 给出文件与**图片**下载的来源白名单:**只有已配置的 Bot API 根**(图片不
|
||||
// 另立名单 —— 两者落的是同一台主机,另立一份只会多一处漂移)。文件地址
|
||||
// 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把
|
||||
// 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package telegram
|
||||
|
||||
// 本文件锁定 Telegram 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):图片与文件
|
||||
// **共用同一份**媒体来源白名单(`assetAllowlist()`:已配置的 Bot API 根),因此图片不需要、
|
||||
// 也**不许**另立第三份名单。
|
||||
//
|
||||
// 覆盖:默认配置下 API 根(`api.telegram.org`)命中、覆盖配置下用配置的根、非名单来源不命中;
|
||||
// 图片经 `file_id → getFile → 下载` 两步取回并内联;以及**反证** —— 平台返回的路径若试图
|
||||
// 把我们引到别的主机(含 `://`),连一次下载请求都不发出去。
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// tgPNG 是一份「字节嗅探认得出来」的 PNG 载荷(嗅探只看前 8 字节签名)。
|
||||
var tgPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// tgPhotoUpdate 构造一条 Telegram **纯图片**消息(无 text/caption)。
|
||||
func tgPhotoUpdate(fileID string, size int64) []byte {
|
||||
raw, err := json.Marshal(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": "主人"},
|
||||
"photo": []map[string]any{
|
||||
{"file_id": fileID, "file_size": size, "width": 1280, "height": 720},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("marshal update: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// tgImageServer 是假的 Bot API + 文件服务端:`file_path` 由调用方给(用来构造「平台想让
|
||||
// 我们下载什么」的各种形状),下载次数单独计数。
|
||||
type tgImageServer struct {
|
||||
srv *httptest.Server
|
||||
filePath string
|
||||
body []byte
|
||||
getFile atomic.Int32
|
||||
fileHits atomic.Int32
|
||||
}
|
||||
|
||||
func newTGImageServer(t *testing.T, filePath string) *tgImageServer {
|
||||
t.Helper()
|
||||
s := &tgImageServer{filePath: filePath, body: tgPNG}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getFile"):
|
||||
s.getFile.Add(1)
|
||||
okResponse(w, map[string]any{"file_id": "photo-1", "file_path": s.filePath})
|
||||
case strings.Contains(r.URL.Path, "/file/bot"):
|
||||
s.fileHits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(s.body)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// newTGImageAdapter 装配一个接到假服务端的适配器 + 捕获器。
|
||||
func newTGImageAdapter(t *testing.T, s *tgImageServer) (*Adapter, *captureHandler) {
|
||||
t.Helper()
|
||||
a := New(
|
||||
Config{BotToken: "123456:TESTTOKEN"},
|
||||
WithBaseURL(s.srv.URL),
|
||||
WithHTTPClient(s.srv.Client()),
|
||||
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(&syncWriter{}, nil)), "plugins/channels/telegram")),
|
||||
)
|
||||
h := &captureHandler{}
|
||||
a.SetMessageHandler(h.fn)
|
||||
return a, h
|
||||
}
|
||||
|
||||
// syncWriter 是并发安全的丢弃型 io.Writer(适配器日志可能在别的 goroutine 里写)。
|
||||
type syncWriter struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *syncWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestTelegramImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:默认配置下
|
||||
// 是 `api.telegram.org`,覆盖 `base_url` 后就是那个根 —— 两者都**只有**配置的根,其余一律
|
||||
// 不命中(deny-by-default)。
|
||||
func TestTelegramImageOriginsAreAllowlisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("默认根", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "123456:TESTTOKEN"})
|
||||
if !a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("默认 API 根没有被白名单覆盖(图片与文件必须共用一份来源名单)")
|
||||
}
|
||||
if a.assetAllowlist().Allowed("https://evil.example.com/photo.jpg") {
|
||||
t.Fatal("非平台来源被白名单放行了")
|
||||
}
|
||||
})
|
||||
t.Run("覆盖根", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, "photos/photo_1.png")
|
||||
a, _ := newTGImageAdapter(t, s)
|
||||
if !a.assetAllowlist().Allowed(s.srv.URL + "/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("覆盖后的 API 根没有被白名单覆盖")
|
||||
}
|
||||
if a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") {
|
||||
t.Fatal("覆盖根之后官方根仍在白名单里(名单必须跟着配置走)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTelegramPhotoIsInlinedFromApiRoot 正向:图片的 `file_id` 先经 getFile 换成平台返回的
|
||||
// 路径,再从**配置的根**下载 ⇒ 字节内联、消息照常投递(纯图片消息的 ContentType 是图片)。
|
||||
func TestTelegramPhotoIsInlinedFromApiRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, "photos/photo_1.png")
|
||||
a, h := newTGImageAdapter(t, s)
|
||||
|
||||
if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil {
|
||||
t.Fatalf("ProcessUpdate: %v", err)
|
||||
}
|
||||
|
||||
msg := h.message(t)
|
||||
if msg.ContentType != adapter.ContentImage || len(msg.Media) != 1 {
|
||||
t.Fatalf("图片消息被归一成了别的形状:%+v", msg)
|
||||
}
|
||||
if !bytes.Equal(msg.Media[0].Data, tgPNG) {
|
||||
t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.fileHits.Load() != 1 || s.getFile.Load() != 1 {
|
||||
t.Fatalf("取回次数 = 下载 %d / getFile %d, want 1/1", s.fileHits.Load(), s.getFile.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramHostileFilePathIsNotDownloaded 是**反证**:平台返回的 `file_path` 试图把我们
|
||||
// 引到别处(含 `://`)时,`sanitizeFilePath` 直接否决 ⇒ **一次下载请求都不发出去**。地址
|
||||
// 既然只由「配置的根 + 平台返回的相对路径」拼成,白名单外的来源在本渠道里根本不可能出现。
|
||||
func TestTelegramHostileFilePathIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
label string
|
||||
filePath string
|
||||
}{
|
||||
{"绝对地址", "https://evil.example.com/photo.png"},
|
||||
{"路径穿越", "../../../etc/passwd"},
|
||||
{"查询串", "photos/photo.png?token=SECRET"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTGImageServer(t, tc.filePath)
|
||||
a, h := newTGImageAdapter(t, s)
|
||||
|
||||
if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil {
|
||||
t.Fatalf("ProcessUpdate: %v", err)
|
||||
}
|
||||
|
||||
if got := s.fileHits.Load(); got != 0 {
|
||||
t.Fatalf("可疑路径仍发起了 %d 次下载,want 0", got)
|
||||
}
|
||||
msg := h.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("可疑路径仍写入了字节:%q", msg.Media[0].Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user