190 lines
6.7 KiB
Go
190 lines
6.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|