439 lines
16 KiB
Go
439 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
type recordedTelegramRequest struct {
|
|
Method string
|
|
Fields map[string]string
|
|
FileField string
|
|
FileName string
|
|
FileData []byte
|
|
}
|
|
|
|
type telegramAPIRecorder struct {
|
|
mu sync.Mutex
|
|
requests []recordedTelegramRequest
|
|
failures map[string]int
|
|
}
|
|
|
|
func (recorder *telegramAPIRecorder) snapshot() []recordedTelegramRequest {
|
|
recorder.mu.Lock()
|
|
defer recorder.mu.Unlock()
|
|
return append([]recordedTelegramRequest(nil), recorder.requests...)
|
|
}
|
|
|
|
func newFakeTelegramAPI(t *testing.T, failures map[string]int) (*httptest.Server, *telegramAPIRecorder) {
|
|
t.Helper()
|
|
recorder := &telegramAPIRecorder{failures: failures}
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
method := strings.TrimPrefix(request.URL.Path, "/")
|
|
recorded := recordedTelegramRequest{Method: method, Fields: map[string]string{}}
|
|
if strings.HasPrefix(request.Header.Get("Content-Type"), "multipart/form-data") {
|
|
if err := request.ParseMultipartForm(64 << 20); err != nil {
|
|
t.Fatalf("parse Telegram multipart request: %v", err)
|
|
}
|
|
for key, values := range request.MultipartForm.Value {
|
|
if len(values) > 0 {
|
|
recorded.Fields[key] = values[0]
|
|
}
|
|
}
|
|
for _, field := range []string{"photo", "document"} {
|
|
files := request.MultipartForm.File[field]
|
|
if len(files) == 0 {
|
|
continue
|
|
}
|
|
file, err := files[0].Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorded.FileData, err = io.ReadAll(file)
|
|
file.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorded.FileField = field
|
|
recorded.FileName = files[0].Filename
|
|
}
|
|
} else {
|
|
if err := request.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, values := range request.Form {
|
|
if len(values) > 0 {
|
|
recorded.Fields[key] = values[0]
|
|
}
|
|
}
|
|
}
|
|
recorder.mu.Lock()
|
|
recorder.requests = append(recorder.requests, recorded)
|
|
fail := recorder.failures[method] > 0
|
|
if fail {
|
|
recorder.failures[method]--
|
|
}
|
|
recorder.mu.Unlock()
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
if fail {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
|
"ok": false, "error_code": 400, "description": "test upload rejection",
|
|
})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{"ok": true, "result": map[string]any{}})
|
|
}))
|
|
return server, recorder
|
|
}
|
|
|
|
func newTelegramMediaTestBot(t *testing.T, telegramURL string, backendURL func(int) string) *telegramBot {
|
|
t.Helper()
|
|
router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, backendURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router.backendMediaURL = backendURL
|
|
bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router)
|
|
bot.apiBase = telegramURL
|
|
return bot
|
|
}
|
|
|
|
func pngFixture() []byte {
|
|
return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("telegram-test-image")...)
|
|
}
|
|
|
|
func TestParseTelegramReplyRemovesOneOrMoreInternalMarkers(t *testing.T) {
|
|
reply := parseTelegramReply("Created these.\n\nMEDIA:/opt/data/cache/images/one.png\nMEDIA:/opt/data/workspace/two.png")
|
|
if reply.Text != "Created these." {
|
|
t.Fatalf("unexpected caption %q", reply.Text)
|
|
}
|
|
if len(reply.MediaPaths) != 2 || reply.MediaPaths[0] != "/opt/data/cache/images/one.png" || reply.MediaPaths[1] != "/opt/data/workspace/two.png" {
|
|
t.Fatalf("unexpected media paths: %#v", reply.MediaPaths)
|
|
}
|
|
if strings.Contains(reply.Text, "MEDIA:") || strings.Contains(reply.Text, "/opt/data") {
|
|
t.Fatalf("internal marker leaked into caption %q", reply.Text)
|
|
}
|
|
}
|
|
|
|
func TestParseTelegramReplyScrubsMalformedMarkersAndBarePrivatePaths(t *testing.T) {
|
|
reply := parseTelegramReply(
|
|
"Delivered. MEDIA: /opt/data/cache/images/one.png and /workspace/private/two.png; MEDIA:",
|
|
)
|
|
if len(reply.MediaPaths) != 1 || reply.MediaPaths[0] != "/opt/data/cache/images/one.png" {
|
|
t.Fatalf("valid spaced marker was not retained for delivery: %#v", reply)
|
|
}
|
|
for _, unsafe := range []string{"MEDIA:", "/opt/data/", "/workspace/"} {
|
|
if strings.Contains(reply.Text, unsafe) {
|
|
t.Fatalf("unsafe transport detail %q leaked in %q", unsafe, reply.Text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTenantMediaPath(t *testing.T) {
|
|
allowed := map[string]string{
|
|
"/workspace/./renders//portrait.png": "/opt/data/workspace/renders/portrait.png",
|
|
"/opt/data/workspace/./reports/result.pdf": "/opt/data/workspace/reports/result.pdf",
|
|
"/opt/data/cache/images//generated/image.png": "/opt/data/cache/images/generated/image.png",
|
|
}
|
|
for input, expected := range allowed {
|
|
actual, err := normalizeTenantMediaPath(input)
|
|
if err != nil || actual != expected {
|
|
t.Errorf("normalize %q: got %q, %v; want %q", input, actual, err, expected)
|
|
}
|
|
}
|
|
for _, input := range []string{
|
|
"relative.png",
|
|
"/opt/data/cache/images/../secrets/token.png",
|
|
"/opt/data/workspace/../../other-tenant/image.png",
|
|
"/opt/data/cache/images-foreign/image.png",
|
|
"/opt/data/tenants/1/image.png",
|
|
`/opt/data/cache/images\..\secret.png`,
|
|
} {
|
|
if normalized, err := normalizeTenantMediaPath(input); err == nil {
|
|
t.Errorf("unsafe path %q normalized to %q", input, normalized)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSendReplyTextOnlyUsesSendMessage(t *testing.T) {
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string {
|
|
t.Fatal("text-only reply attempted a tenant media request")
|
|
return ""
|
|
})
|
|
if err := bot.sendReply(42, 0, "Hello from Hermes"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 1 || requests[0].Method != "sendMessage" || requests[0].Fields["text"] != "Hello from Hermes" {
|
|
t.Fatalf("unexpected Telegram requests: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func TestSendReplyUploadsPhotoWithCaptionThroughTenantAuth(t *testing.T) {
|
|
var mediaRequests int
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
mediaRequests++
|
|
if request.URL.Path != "/media" || request.URL.Query().Get("path") != "/opt/data/workspace/renders/portrait.png" {
|
|
t.Fatalf("unexpected tenant media URL %s", request.URL.String())
|
|
}
|
|
if request.Header.Get("Authorization") != "Bearer relay-secret" {
|
|
t.Fatal("tenant media relay authentication was not set")
|
|
}
|
|
if request.Header.Get("X-Hermes-Tenant-Slot") != "1" {
|
|
t.Fatalf("unexpected tenant slot %q", request.Header.Get("X-Hermes-Tenant-Slot"))
|
|
}
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(slot int) string {
|
|
if slot != 1 {
|
|
t.Fatalf("requested media from slot %d", slot)
|
|
}
|
|
return tenant.URL
|
|
})
|
|
if err := bot.sendReply(42, 1, "Created it.\nMEDIA:/workspace/./renders/portrait.png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 1 || requests[0].Method != "sendPhoto" || requests[0].FileField != "photo" {
|
|
t.Fatalf("unexpected Telegram requests: %#v", requests)
|
|
}
|
|
if requests[0].Fields["caption"] != "Created it." || requests[0].Fields["chat_id"] != "42" {
|
|
t.Fatalf("unexpected photo fields: %#v", requests[0].Fields)
|
|
}
|
|
if requests[0].FileName != "portrait.png" || string(requests[0].FileData) != string(pngFixture()) {
|
|
t.Fatalf("unexpected uploaded photo: %#v", requests[0])
|
|
}
|
|
}
|
|
|
|
func TestSendReplyUploadsMultipleMarkersAndCaptionsOnlyFirst(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
path := request.URL.Query().Get("path")
|
|
switch filepath.Ext(path) {
|
|
case ".png":
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
case ".pdf":
|
|
writer.Header().Set("Content-Type", "application/pdf")
|
|
_, _ = writer.Write([]byte("%PDF-1.4 test"))
|
|
default:
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
err := bot.sendReply(7, 0, "Two files.\nMEDIA:/opt/data/cache/images/one.png\nMEDIA:/opt/data/workspace/two.pdf")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 2 || requests[0].Method != "sendPhoto" || requests[1].Method != "sendDocument" {
|
|
t.Fatalf("unexpected Telegram methods: %#v", requests)
|
|
}
|
|
if requests[0].Fields["caption"] != "Two files." || requests[1].Fields["caption"] != "" {
|
|
t.Fatalf("caption was not scoped to first upload: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func TestSendReplySendsLongCaptionSeparately(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
caption := strings.Repeat("detail ", 180)
|
|
if err := bot.sendReply(7, 0, caption+"\nMEDIA:/opt/data/cache/images/one.png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 2 || requests[0].Method != "sendMessage" || requests[1].Method != "sendPhoto" {
|
|
t.Fatalf("long caption was not sent separately: %#v", requests)
|
|
}
|
|
if requests[0].Fields["text"] != strings.TrimSpace(caption) || requests[1].Fields["caption"] != "" {
|
|
t.Fatalf("long caption leaked into photo upload: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func TestSendReplyRejectsTraversalWithoutFetchingTenant(t *testing.T) {
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string {
|
|
t.Fatal("traversal path reached the tenant backend")
|
|
return ""
|
|
})
|
|
if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/../auth.json"); err == nil {
|
|
t.Fatal("expected traversal rejection")
|
|
}
|
|
assertOnlySafeFallback(t, recorder.snapshot())
|
|
}
|
|
|
|
func TestSendReplyRejectsTenantSymlinkEscape(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "images")
|
|
outside := filepath.Join(t.TempDir(), "outside.png")
|
|
if err := os.MkdirAll(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(outside, pngFixture(), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "escape.png")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
logical := request.URL.Query().Get("path")
|
|
relative := strings.TrimPrefix(logical, "/opt/data/cache/images/")
|
|
target, err := filepath.EvalSymlinks(filepath.Join(root, relative))
|
|
if err != nil || !pathWithin(target, root) {
|
|
writer.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
_, _ = os.Open(target)
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/escape.png"); err == nil {
|
|
t.Fatal("expected tenant symlink rejection")
|
|
}
|
|
assertOnlySafeFallback(t, recorder.snapshot())
|
|
}
|
|
|
|
func TestSendReplyHandlesMissingTenantFile(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/missing.png"); err == nil {
|
|
t.Fatal("expected missing-file error")
|
|
}
|
|
assertOnlySafeFallback(t, recorder.snapshot())
|
|
}
|
|
|
|
func TestSendPhotoFailureFallsBackToDocument(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, map[string]int{"sendPhoto": 1})
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
if err := bot.sendReply(11, 0, "Portrait.\nMEDIA:/opt/data/cache/images/portrait.png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 2 || requests[0].Method != "sendPhoto" || requests[1].Method != "sendDocument" {
|
|
t.Fatalf("photo did not fall back to document: %#v", requests)
|
|
}
|
|
if requests[1].Fields["caption"] != "Portrait." || requests[1].FileField != "document" {
|
|
t.Fatalf("document fallback lost content: %#v", requests[1])
|
|
}
|
|
}
|
|
|
|
func TestMislabeledImageUsesSniffedDocumentType(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write([]byte("%PDF-1.4 mislabeled"))
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
if err := bot.sendReply(11, 0, "MEDIA:/opt/data/cache/images/mislabeled.png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 1 || requests[0].Method != "sendDocument" {
|
|
t.Fatalf("mislabeled media was not routed as a document: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func TestUploadFailureSendsSafeTextWithoutInternalPath(t *testing.T) {
|
|
tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
}))
|
|
defer tenant.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, map[string]int{"sendPhoto": 1, "sendDocument": 1})
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL })
|
|
if err := bot.sendReply(11, 0, "Created it.\nMEDIA:/opt/data/cache/images/private.png"); err == nil {
|
|
t.Fatal("expected upload failure")
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 4 || requests[2].Method != "sendMessage" || requests[3].Method != "sendMessage" {
|
|
t.Fatalf("unexpected failure requests: %#v", requests)
|
|
}
|
|
for _, request := range requests {
|
|
for _, value := range request.Fields {
|
|
if strings.Contains(value, "MEDIA:") || strings.Contains(value, "/opt/data/") {
|
|
t.Fatalf("internal path leaked to Telegram: %#v", requests)
|
|
}
|
|
}
|
|
}
|
|
if requests[2].Fields["text"] == "" || requests[3].Fields["text"] != "Created it." {
|
|
t.Fatalf("safe fallback or response text missing: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func TestSendReplyUsesOnlyLinkedTenantSlot(t *testing.T) {
|
|
tenantZero := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("cross-tenant media request reached slot zero")
|
|
}))
|
|
defer tenantZero.Close()
|
|
tenantOne := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Header.Get("X-Hermes-Tenant-Slot") != "1" {
|
|
t.Fatalf("unexpected tenant header %q", request.Header.Get("X-Hermes-Tenant-Slot"))
|
|
}
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
_, _ = writer.Write(pngFixture())
|
|
}))
|
|
defer tenantOne.Close()
|
|
telegram, recorder := newFakeTelegramAPI(t, nil)
|
|
defer telegram.Close()
|
|
bot := newTelegramMediaTestBot(t, telegram.URL, func(slot int) string {
|
|
return []string{tenantZero.URL, tenantOne.URL}[slot]
|
|
})
|
|
if err := bot.sendReply(99, 1, "MEDIA:/opt/data/cache/images/tenant-one.png"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests := recorder.snapshot()
|
|
if len(requests) != 1 || requests[0].Fields["chat_id"] != strconv.FormatInt(99, 10) {
|
|
t.Fatalf("unexpected Telegram delivery: %#v", requests)
|
|
}
|
|
}
|
|
|
|
func assertOnlySafeFallback(t *testing.T, requests []recordedTelegramRequest) {
|
|
t.Helper()
|
|
if len(requests) != 1 || requests[0].Method != "sendMessage" {
|
|
t.Fatalf("expected only a safe fallback message, got %#v", requests)
|
|
}
|
|
text := requests[0].Fields["text"]
|
|
if text == "" || strings.Contains(text, "MEDIA:") || strings.Contains(text, "/opt/data/") {
|
|
t.Fatalf("unsafe fallback %q", text)
|
|
}
|
|
}
|