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) } }) } }