Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (

"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
"github.com/Wikid82/charon/backend/internal/util"
"gorm.io/gorm"
Expand Down Expand Up @@ -575,11 +574,7 @@ func (s *EnhancedSecurityNotificationService) dispatchToProvider(ctx context.Con
// Blocker 4: SSRF-safe URL validation before outbound requests.
func (s *EnhancedSecurityNotificationService) sendWebhook(ctx context.Context, webhookURL string, event models.SecurityEvent) error {
// Blocker 4: Validate URL before making outbound request (SSRF protection)
validatedURL, err := security.ValidateExternalURL(webhookURL,
security.WithAllowHTTP(), // Allow HTTP for backwards compatibility
security.WithAllowLocalhost(), // Allow localhost for testing
security.WithAllowCGNAT(),
)
validatedURL, err := security.ValidateExternalURL(webhookURL, senderURLOptions()...)
if err != nil {
return fmt.Errorf("ssrf validation failed: %w", err)
}
Expand All @@ -598,11 +593,7 @@ func (s *EnhancedSecurityNotificationService) sendWebhook(ctx context.Context, w
req.Header.Set("User-Agent", "Charon-Cerberus/1.0")

// Validated outbound client: dial-time address validation, no redirects, no proxy.
client := network.NewSafeHTTPClient(
network.WithTimeout(10*time.Second),
network.WithAllowLocalhost(), // Allow localhost for testing
network.WithAllowCGNAT(),
)
client := newSenderHTTPClient()
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func TestDiscordOnly_DispatchToProviderRejectsNonDiscord(t *testing.T) {

// TestDiscordOnly_DispatchToProviderAcceptsDiscord tests that dispatchToProvider accepts Discord providers.
func TestDiscordOnly_DispatchToProviderAcceptsDiscord(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ func TestEnhancedService_IsFeatureEnabled_CreateAndRequeryPath(t *testing.T) {
}

func TestEnhancedService_SendViaProviders_QueryProvidersErrorAndCrowdSecRouting(t *testing.T) {
setSenderAllowLoopbackForTest(t)
t.Run("query_providers_error", func(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
Expand Down Expand Up @@ -676,6 +677,7 @@ func TestEnhancedService_SendViaProviders_QueryProvidersErrorAndCrowdSecRouting(
}

func TestEnhancedService_SendWebhook_MarshalAndExecuteErrorPaths(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
service := NewEnhancedSecurityNotificationService(db)
Expand Down Expand Up @@ -755,6 +757,7 @@ func TestEnhancedService_IsFeatureEnabled_CreateAndRequeryErrorPath(t *testing.T
}

func TestEnhancedService_SendViaProviders_RateLimitRoutingBranch(t *testing.T) {
setSenderAllowLoopbackForTest(t)
serverCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serverCalls++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,7 @@ func TestSendWebhook_SSRFValidation(t *testing.T) {
}

func TestSendWebhook_Success(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupEnhancedServiceDB(t)
service := NewEnhancedSecurityNotificationService(db)

Expand Down Expand Up @@ -974,6 +975,7 @@ func TestGetDefaultFeatureFlagValue_TestMode(t *testing.T) {
}

func TestSendWebhook_DoesNotFollowRedirects(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupEnhancedServiceDB(t)
service := NewEnhancedSecurityNotificationService(db)

Expand Down
36 changes: 36 additions & 0 deletions backend/internal/services/notification_sender_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package services

import (
"net/http"
"time"

"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
)

// senderAllowLoopback is a test-only seam. It is always false in production
// and is only toggled from _test.go files (see setSenderAllowLoopbackForTest).
// Tests that toggle it must not call t.Parallel(), as it is shared package state.
var senderAllowLoopback bool

// senderURLOptions returns the URL validation options shared by the
// notification senders. CGNAT (overlay networks) is always allowed; loopback is
// opt-in via the test seam only.
func senderURLOptions() []security.ValidationOption {
opts := []security.ValidationOption{security.WithAllowHTTP(), security.WithAllowCGNAT()}
if senderAllowLoopback {
opts = append(opts, security.WithAllowLocalhost())
}
return opts
}

// newSenderHTTPClient builds the validated outbound client shared by the
// notification senders. CGNAT (overlay networks) is always allowed; loopback is
// opt-in via the test seam only.
func newSenderHTTPClient() *http.Client {
opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithAllowCGNAT()}
if senderAllowLoopback {
opts = append(opts, network.WithAllowLocalhost())
}
return network.NewSafeHTTPClient(opts...)
}
99 changes: 99 additions & 0 deletions backend/internal/services/notification_sender_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package services

import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/security"
)

// setSenderAllowLoopbackForTest enables the test-only loopback seam for the
// duration of the test.
func setSenderAllowLoopbackForTest(t *testing.T) {
t.Helper()
prev := senderAllowLoopback
senderAllowLoopback = true
t.Cleanup(func() { senderAllowLoopback = prev })
}

func TestSenderAllowLoopbackDefaultsFalse(t *testing.T) {
assert.False(t, senderAllowLoopback)
}

func TestSenderClientRejectsLoopbackByDefault(t *testing.T) {
for _, u := range []string{
"http://127.0.0.1:8080/x", "http://[::1]:8080/x", "http://localhost:8080/x",
"http://0.0.0.0:8080/x", "http://[::ffff:127.0.0.1]:8080/x", "http://127.0.0.2:8080/x",
} {
_, err := security.ValidateExternalURL(u, senderURLOptions()...)
require.Error(t, err, u)
assert.Contains(t, err.Error(), "private ip addresses is blocked", u)

// Dial-time layer must also refuse, independent of URL validation.
req, reqErr := http.NewRequest(http.MethodPost, u, http.NoBody)
require.NoError(t, reqErr)
resp, doErr := newSenderHTTPClient().Do(req)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, doErr, u)
}
}

func TestSendersRejectLoopbackDestinations(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("loopback server must not be contacted")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

event := models.SecurityEvent{EventType: "waf_block", Severity: "warn", Message: "x"}
err := (&SecurityNotificationService{}).sendWebhook(context.Background(), srv.URL, event)
assert.Error(t, err)
err = (&EnhancedSecurityNotificationService{}).sendWebhook(context.Background(), srv.URL, event)
assert.Error(t, err)
}

func TestSenderSeamPermitsLoopbackInTests(t *testing.T) {
setSenderAllowLoopbackForTest(t)
var hit atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit.Store(true)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

event := models.SecurityEvent{EventType: "waf_block", Severity: "warn", Message: "x"}
require.NoError(t, (&SecurityNotificationService{}).sendWebhook(context.Background(), srv.URL, event))
require.True(t, hit.Load())
hit.Store(false)
require.NoError(t, (&EnhancedSecurityNotificationService{}).sendWebhook(context.Background(), srv.URL, event))
assert.True(t, hit.Load())
}

func TestSenderHelpers_AllowCGNATRejectTransitionByDefault(t *testing.T) {
require.False(t, senderAllowLoopback)

_, err := security.ValidateExternalURL("http://100.64.0.1/hook", senderURLOptions()...)
assert.NoError(t, err, "CGNAT must be allowed")
for _, u := range []string{
"http://100.100.100.200/hook", "http://127.0.0.1/hook",
"http://198.18.0.1/hook", "http://[2002::1]/hook", "http://[64:ff9b::808:808]/hook",
} {
_, err := security.ValidateExternalURL(u, senderURLOptions()...)
assert.Error(t, err, u)
}

client := newSenderHTTPClient()
assert.False(t, dialPolicyProbe(t, client, "100.64.0.1:9"), "CGNAT dial refused")
for _, addr := range []string{"100.100.100.200:9", "127.0.0.1:9", "198.18.0.1:9", "[2002::1]:9", "[64:ff9b::808:808]:9"} {
assert.True(t, dialPolicyProbe(t, client, addr), addr)
}
}
14 changes: 2 additions & 12 deletions backend/internal/services/security_notification_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
Expand Down Expand Up @@ -100,11 +98,7 @@ func (s *SecurityNotificationService) Send(ctx context.Context, event models.Sec
// sendWebhook sends the event to a webhook URL.
func (s *SecurityNotificationService) sendWebhook(ctx context.Context, webhookURL string, event models.SecurityEvent) error {
// CRITICAL FIX: Validate webhook URL before making request (SSRF protection)
validatedURL, err := security.ValidateExternalURL(webhookURL,
security.WithAllowLocalhost(), // Allow localhost for testing
security.WithAllowHTTP(), // Some webhooks use HTTP
security.WithAllowCGNAT(),
)
validatedURL, err := security.ValidateExternalURL(webhookURL, senderURLOptions()...)
if err != nil {
// Log SSRF attempt with high severity
logger.Log().WithFields(logrus.Fields{
Expand All @@ -131,11 +125,7 @@ func (s *SecurityNotificationService) sendWebhook(ctx context.Context, webhookUR
req.Header.Set("User-Agent", "Charon-Cerberus/1.0")

// Use SSRF-safe HTTP client for defense-in-depth
client := network.NewSafeHTTPClient(
network.WithTimeout(10*time.Second),
network.WithAllowLocalhost(), // Allow localhost for testing
network.WithAllowCGNAT(),
)
client := newSenderHTTPClient()
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func TestSecurityNotificationService_Send_FilteredBySeverity(t *testing.T) {
}

func TestSecurityNotificationService_Send_WebhookFailure(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupSecurityNotifTestDB(t)
svc := NewSecurityNotificationService(db)

Expand Down Expand Up @@ -204,6 +205,7 @@ func TestShouldNotify(t *testing.T) {
}

func TestSecurityNotificationService_Send_ACLDeny(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupSecurityNotifTestDB(t)
svc := NewSecurityNotificationService(db)

Expand Down Expand Up @@ -370,6 +372,7 @@ func TestSecurityNotificationService_Send_SeverityBelowThreshold(t *testing.T) {

// TestSecurityNotificationService_Send_WebhookSuccess tests successful webhook dispatch.
func TestSecurityNotificationService_Send_WebhookSuccess(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupSecurityNotifTestDB(t)
svc := NewSecurityNotificationService(db)

Expand Down Expand Up @@ -491,6 +494,7 @@ func TestSecurityNotificationService_sendWebhook_RequestExecutionError(t *testin

// TestSecurityNotificationService_sendWebhook_Non200Status tests non-2xx HTTP status handling.
func TestSecurityNotificationService_sendWebhook_Non200Status(t *testing.T) {
setSenderAllowLoopbackForTest(t)
db := setupSecurityNotifTestDB(t)
svc := NewSecurityNotificationService(db)

Expand Down
2 changes: 2 additions & 0 deletions docs/features/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ JSON templates give you complete control over notification formatting, allowing
5. Configure notification triggers
6. Save your provider

> **Receivers on the same host:** Notification webhooks cannot target loopback addresses (`localhost`, `127.0.0.1`, `::1`). If your receiver runs on the same machine as Charon, use the host's non-loopback address instead (for example its LAN IP or a Docker network hostname).

### JSON Template Support

For JSON-based services (Discord, Slack, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates.
Expand Down
Loading
Loading