diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 7f1476e9..7d8a08c6 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -26,16 +26,18 @@ import ( ) type NotificationService struct { - DB *gorm.DB - httpWrapper *notifications.HTTPWrapper - mailService MailServiceInterface + DB *gorm.DB + httpWrapper *notifications.HTTPWrapper + mailService MailServiceInterface + telegramAPIBaseURL string } func NewNotificationService(db *gorm.DB, mailService MailServiceInterface) *NotificationService { return &NotificationService{ - DB: db, - httpWrapper: notifications.NewNotifyHTTPWrapper(), - mailService: mailService, + DB: db, + httpWrapper: notifications.NewNotifyHTTPWrapper(), + mailService: mailService, + telegramAPIBaseURL: "https://api.telegram.org", } } @@ -489,10 +491,19 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti if providerType == "telegram" { decryptedToken := p.Token - dispatchURL = "https://api.telegram.org/bot" + decryptedToken + "/sendMessage" + telegramBase := s.telegramAPIBaseURL + if telegramBase == "" { + telegramBase = "https://api.telegram.org" + } + dispatchURL = telegramBase + "/bot" + decryptedToken + "/sendMessage" parsedURL, parseErr := neturl.Parse(dispatchURL) - if parseErr != nil || parsedURL.Hostname() != "api.telegram.org" { + expectedHost := "api.telegram.org" + if parsedURL != nil && parsedURL.Hostname() != "" && telegramBase != "https://api.telegram.org" { + // In test overrides, skip the hostname pin check. + expectedHost = parsedURL.Hostname() + } + if parseErr != nil || parsedURL.Hostname() != expectedHost { return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname") } diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 5d14bada..1e3d9dc9 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -502,27 +502,20 @@ func TestTestProvider_UsesJSONForSupportedServices(t *testing.T) { } func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) { + var capturedPayload map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - err := json.NewDecoder(r.Body).Decode(&payload) + err := json.NewDecoder(r.Body).Decode(&capturedPayload) require.NoError(t, err) - assert.NotNil(t, payload["text"], "Telegram payload should have text field") - assert.NotNil(t, payload["chat_id"], "Telegram payload should have chat_id field") w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() - // Extract host from test server to override SSRF check - serverURL, _ := url.Parse(server.URL) - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) - - // Override httpWrapper.Send to capture the request and forward to test server - origWrapper := svc.httpWrapper - _ = origWrapper + svc.telegramAPIBaseURL = server.URL provider := models.NotificationProvider{ Type: "telegram", @@ -537,25 +530,26 @@ func TestSendJSONPayload_Telegram_ValidPayload(t *testing.T) { "Title": "Test", } - // We need to test the payload construction, not the actual HTTP call. - // The SSRF check validates hostname = api.telegram.org, so the httpWrapper.Send - // will be called with the constructed URL. We test validation logic directly. sendErr := svc.sendJSONPayload(context.Background(), provider, data) - // The send will fail because api.telegram.org is not reachable in tests, - // but the payload construction and validation should succeed. - // Check that the error is a network error, not a validation error. - if sendErr != nil { - assert.NotContains(t, sendErr.Error(), "telegram payload requires") - assert.NotContains(t, sendErr.Error(), "telegram dispatch URL validation failed") - } - _ = serverURL + require.NoError(t, sendErr) + assert.NotNil(t, capturedPayload["text"], "Telegram payload should have text field") + assert.NotNil(t, capturedPayload["chat_id"], "Telegram payload should have chat_id field") } func TestSendJSONPayload_Telegram_AutoMapMessageToText(t *testing.T) { + var capturedPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&capturedPayload) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) + svc.telegramAPIBaseURL = server.URL provider := models.NotificationProvider{ Type: "telegram", @@ -571,10 +565,9 @@ func TestSendJSONPayload_Telegram_AutoMapMessageToText(t *testing.T) { } sendErr := svc.sendJSONPayload(context.Background(), provider, data) - // Should not fail with validation error — 'message' is auto-mapped to 'text' - if sendErr != nil { - assert.NotContains(t, sendErr.Error(), "telegram payload requires 'text' field") - } + // 'message' must be auto-mapped to 'text' — dispatch must succeed. + require.NoError(t, sendErr) + assert.Equal(t, "Test notification", capturedPayload["text"], "'message' should be auto-mapped to 'text'") } func TestSendJSONPayload_Telegram_MissingTextAndMessage(t *testing.T) { @@ -601,14 +594,22 @@ func TestSendJSONPayload_Telegram_MissingTextAndMessage(t *testing.T) { } func TestSendJSONPayload_Telegram_SSRFValidation(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) svc := NewNotificationService(db, nil) + svc.telegramAPIBaseURL = server.URL - // Path traversal in token does NOT change hostname — Go's net/url.Parse - // keeps api.telegram.org as the host. The request reaches the real API - // (which rejects it), proving hostname validation cannot be bypassed. + // Path traversal in token: Go's net/http transport cleans the URL path, + // so "/../../../evil.com/x" does not escape the server host. provider := models.NotificationProvider{ Type: "telegram", URL: "123456789", @@ -622,10 +623,12 @@ func TestSendJSONPayload_Telegram_SSRFValidation(t *testing.T) { } sendErr := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, sendErr) - // Hostname validation passes (host IS api.telegram.org), so error comes - // from the HTTP dispatch layer — proving SSRF check was not bypassed. - assert.NotContains(t, sendErr.Error(), "telegram dispatch URL validation failed") + // Dispatch must succeed (no validation error) — the path traversal in the + // token cannot redirect the request to a different host. The request was + // received by our local server, not by evil.com. + require.NoError(t, sendErr) + // capturedPath is non-empty only if our server handled the request. + assert.NotEmpty(t, capturedPath, "request must have been served by the local test server, not redirected to evil.com") } func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) { diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index a38f69d1..d79f7b50 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2999,7 +2999,19 @@ func TestIsDispatchEnabled_TelegramDefaultTrue(t *testing.T) { func TestSendJSONPayload_Telegram_ChatIDInjectionAndDispatch(t *testing.T) { db := setupNotificationTestDB(t) + + var capturedPath string + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + svc := NewNotificationService(db, nil) + svc.telegramAPIBaseURL = server.URL provider := models.NotificationProvider{ Type: "telegram", @@ -3014,19 +3026,31 @@ func TestSendJSONPayload_Telegram_ChatIDInjectionAndDispatch(t *testing.T) { "EventType": "test", } - // The telegram branch constructs https://api.telegram.org/bot.../sendMessage - // which will fail at httpWrapper.Send (unreachable in test env). - // This still exercises lines 490-506 (chat_id injection, marshal, body write). err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to send webhook") + require.NoError(t, err) + assert.Equal(t, "/botfake-bot-token/sendMessage", capturedPath) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.Equal(t, "123456789", payload["chat_id"]) + assert.Equal(t, "Hello Telegram", payload["text"]) } func TestSendJSONPayload_Telegram_NormalizesMessageToText(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - // Custom template that produces "message" key instead of "text" + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + svc := NewNotificationService(db, nil) + svc.telegramAPIBaseURL = server.URL + + // Custom template that produces "message" key instead of "text" — exercises normalization. provider := models.NotificationProvider{ Type: "telegram", URL: "987654321", @@ -3041,10 +3065,14 @@ func TestSendJSONPayload_Telegram_NormalizesMessageToText(t *testing.T) { "EventType": "test", } - // Exercises telegram normalization (message→text) + chat_id injection err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to send webhook") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + // "message" must be promoted to "text" by the normalization path. + assert.Equal(t, "Normalize me", payload["text"]) + assert.Equal(t, "987654321", payload["chat_id"]) } func TestSendJSONPayload_Telegram_RequiresTextField(t *testing.T) { @@ -3097,15 +3125,19 @@ func TestSendJSONPayload_Telegram_HostnameValidationError(t *testing.T) { func TestSendJSONPayload_Telegram_MarshalErrorOnChatIDInjection(t *testing.T) { db := setupNotificationTestDB(t) - svc := NewNotificationService(db, nil) - // Use a httptest server that accepts the request so we can reach - // the chat_id injection code. We use a "webhook" provider first to - // verify the marshal path, but here we directly exercise the telegram - // path by injecting an unmarshalable value into jsonPayload via a - // custom template that produces a valid JSON with a func value key. - // Since json.Marshal can't fail on map[string]any with string values, - // we exercise the happy path for marshal and verify the body is correct. + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + svc := NewNotificationService(db, nil) + svc.telegramAPIBaseURL = server.URL + + // Exercises the chat_id injection + marshal + body.Write path. provider := models.NotificationProvider{ Type: "telegram", URL: "999888777", @@ -3119,11 +3151,13 @@ func TestSendJSONPayload_Telegram_MarshalErrorOnChatIDInjection(t *testing.T) { "EventType": "test", } - // This exercises the chat_id injection + marshal + body.Write path - // (lines 499-505) then fails at httpWrapper.Send (unreachable host). err := svc.sendJSONPayload(context.Background(), provider, data) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to send webhook") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.Equal(t, "999888777", payload["chat_id"]) + assert.NotEmpty(t, payload["text"]) } func TestIsDispatchEnabled_TelegramDisabledByFlag(t *testing.T) {