diff --git a/backend/internal/models/notification_config.go b/backend/internal/models/notification_config.go index 7bfed565..21c8518a 100644 --- a/backend/internal/models/notification_config.go +++ b/backend/internal/models/notification_config.go @@ -14,10 +14,10 @@ type NotificationConfig struct { MinLogLevel string `json:"min_log_level"` // error, warn, info, debug WebhookURL string `json:"webhook_url"` // Blocker 2 Fix: API surface uses security_* field names per spec (internal fields remain notify_*) - NotifyWAFBlocks bool `json:"security_waf_enabled"` - NotifyACLDenies bool `json:"security_acl_enabled"` - NotifyRateLimitHits bool `json:"security_rate_limit_enabled"` - NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"` + NotifyWAFBlocks bool `json:"security_waf_enabled"` + NotifyACLDenies bool `json:"security_acl_enabled"` + NotifyRateLimitHits bool `json:"security_rate_limit_enabled"` + NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"` // Legacy destination fields (compatibility, not stored in DB) DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"` diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go index 4e9ff39f..39372f75 100644 --- a/backend/internal/services/mail_service.go +++ b/backend/internal/services/mail_service.go @@ -361,8 +361,10 @@ func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlB return err } default: - // Safe: CRLF rejected in header values; address parsed by net/mail; body dot-stuffed; see buildEmail() and rejectCRLF(). - if err := smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg); err != nil { // codeql[go/email-injection] + // Defense-in-depth: CRLF rejected in all header values by rejectCRLF(), + // addresses parsed by net/mail, body dot-stuffed by sanitizeEmailBody(), + // and inputs pre-sanitized by sanitizeForEmail() at the notification boundary. + if err := smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg); err != nil { return err } } @@ -534,8 +536,9 @@ func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, f return fmt.Errorf("DATA failed: %w", err) } - // Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321. - if _, writeErr := w.Write(msg); writeErr != nil { // codeql[go/email-injection] + // Defense-in-depth: msg built by buildEmail() which rejects CRLF in headers via rejectCRLF(), + // sanitizes body via sanitizeEmailBody(), and inputs pre-sanitized by sanitizeForEmail(). + if _, writeErr := w.Write(msg); writeErr != nil { return fmt.Errorf("failed to write message: %w", writeErr) } @@ -586,8 +589,9 @@ func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Au return fmt.Errorf("DATA failed: %w", err) } - // Safe: msg is built by buildEmail() which rejects CRLF in headers and sanitizes the body; net/smtp data.Writer dot-stuffs per RFC 5321. - if _, err := w.Write(msg); err != nil { // codeql[go/email-injection] + // Defense-in-depth: msg built by buildEmail() which rejects CRLF in headers via rejectCRLF(), + // sanitizes body via sanitizeEmailBody(), and inputs pre-sanitized by sanitizeForEmail(). + if _, err := w.Write(msg); err != nil { return fmt.Errorf("failed to write message: %w", err) } diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go index 85f5474b..d3b96a51 100644 --- a/backend/internal/services/mail_service_test.go +++ b/backend/internal/services/mail_service_test.go @@ -11,13 +11,13 @@ import ( "crypto/x509/pkix" "encoding/pem" "errors" + "fmt" "math/big" "net" "net/mail" "os" "path/filepath" "strconv" - "fmt" "strings" "sync" "testing" diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 4afbd308..6ee10f8e 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -245,6 +245,15 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } } +// sanitizeForEmail strips CR/LF characters from untrusted strings +// before they enter the email pipeline. This provides defense-in-depth +// alongside rejectCRLF() validation in SendEmail/buildEmail. +func sanitizeForEmail(s string) string { + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + return s +} + // dispatchEmail sends an email notification for the given provider. // It runs in a goroutine; all errors are logged rather than returned. func (s *NotificationService) dispatchEmail(ctx context.Context, p models.NotificationProvider, _, title, message string) { @@ -266,8 +275,10 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi return } - subject := fmt.Sprintf("[Charon Alert] %s", title) - htmlBody := "
" + html.EscapeString(title) + "
" + html.EscapeString(message) + "
" + safeTitle := sanitizeForEmail(title) + safeMessage := sanitizeForEmail(message) + subject := fmt.Sprintf("[Charon Alert] %s", safeTitle) + htmlBody := "" + html.EscapeString(safeTitle) + "
" + html.EscapeString(safeMessage) + "
" timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 759a4f19..6aae4613 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2787,3 +2787,59 @@ func TestDispatchEmail_XSSPayload_BodySanitized(t *testing.T) { assert.NotContains(t, body, "" + html.EscapeString(title) + "
" + html.EscapeString(message) + "
" + +// After: +safeTitle := sanitizeForEmail(title) +safeMessage := sanitizeForEmail(message) +subject := fmt.Sprintf("[Charon Alert] %s", safeTitle) +htmlBody := "" + html.EscapeString(safeTitle) + "
" + html.EscapeString(safeMessage) + "
" +``` + +**Why this may help CodeQL**: `strings.ReplaceAll` for `\r` and `\n` is a pattern that CodeQL's taint model recognizes as a sanitizer for email injection. + +### 4.2 Approach B — Sanitize at the `SendEmail` Boundary (Defense-in-Depth) + +Add a `sanitizeForEmail()` call on the `htmlBody` and `subject` parameters inside `SendEmail()` itself, so all callers benefit: + +```go +func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlBody string) error { + // Strip CRLF from subject and body as defense-in-depth + subject = sanitizeForEmail(subject) + htmlBody = sanitizeForEmail(htmlBody) + // ... existing validation follows +} +``` + +**Trade-off**: Stripping `\n` from `htmlBody` would break HTML formatting. This approach works for `subject` but NOT for `htmlBody`. For the body, the existing `html.EscapeString()` + dot-stuffing is the correct defense. + +**Revised**: Apply `sanitizeForEmail()` to `subject` only in `SendEmail()`. The HTML body should retain newlines for formatting but is protected by `html.EscapeString()` at the call site and dot-stuffing in `buildEmail()`. + +### 4.3 Approach C — GitHub UI Alert Dismissal (Fallback) + +If CodeQL continues to flag after code restructuring, dismiss the remaining alerts in the GitHub Code Scanning UI with: + +- **Reason**: "False positive" +- **Comment**: "Mitigated by defense-in-depth: CRLF rejection (rejectCRLF), MIME Q-encoding (encodeSubject), html.EscapeString on body content, dot-stuffing (sanitizeEmailBody), undisclosed recipients in To header. See docs/plans/current_spec.md §3.2 for full analysis." + +### 4.4 Selected Strategy + +**Combine A + C:** +1. Add `sanitizeForEmail()` at the `dispatchEmail()` boundary (Approach A) — this is the cleanest fix and may satisfy CodeQL +2. If CodeQL still flags after the restructuring, dismiss via GitHub UI (Approach C) +3. Do NOT strip newlines from HTML body (Approach B partial) — it would break email formatting + +--- + +## 5. Implementation Plan + +### Phase 1: Add Sanitization Function + +**File**: `backend/internal/services/notification_service.go` + +| Task | Description | +|------|-------------| +| 5.1.1 | Add `sanitizeForEmail(s string) string` that strips `\r` and `\n` via `strings.ReplaceAll` | +| 5.1.2 | In `dispatchEmail()`, apply `sanitizeForEmail()` to `title` and `message` before constructing `subject` and `htmlBody` | +| 5.1.3 | Add unit test for `sanitizeForEmail()` covering: empty string, clean string, string with `\r\n`, string with embedded `\n` | + +### Phase 2: Unit Tests for Email Injection Prevention + +**File**: `backend/internal/services/mail_service_test.go` (existing or new) + +| Task | Description | +|------|-------------| +| 5.2.1 | Add test: notification with CRLF in entity name → email sent without injection | +| 5.2.2 | Add test: `dispatchEmail` with `title` containing `\r\nBCC: attacker@evil.com` → CRLF stripped before subject | +| 5.2.3 | Add test: verify `html.EscapeString` prevents `