fit(notification): enhance Telegram integration with dynamic API base URL and improved payload validation
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user