fit(notification): enhance Telegram integration with dynamic API base URL and improved payload validation

This commit is contained in:
GitHub Actions
2026-03-11 00:34:39 +00:00
parent 7416229ba3
commit c977c6f9a4
3 changed files with 111 additions and 63 deletions

View File

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

View File

@@ -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) {

View File

@@ -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) {