diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go index d326b544..cafa6230 100644 --- a/backend/internal/services/mail_service.go +++ b/backend/internal/services/mail_service.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "errors" "fmt" + "html" "html/template" "mime" "net/mail" @@ -329,6 +330,9 @@ func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlB auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) } + // Normalize and sanitize the email body so that any untrusted input is + // treated as plain text and cannot break out of the HTML context. + htmlBody = sanitizeAndNormalizeHTMLBody(htmlBody) htmlBody = sanitizeEmailContent(htmlBody) for _, recipient := range to { @@ -494,6 +498,31 @@ func sanitizeEmailContent(body string) string { }, body) } +// sanitizeAndNormalizeHTMLBody converts an arbitrary string (potentially containing +// untrusted input) into a safe HTML fragment. It splits on newlines, escapes each +// line as plain text, and wraps non-empty lines in

tags. This ensures that +// user input cannot inject raw HTML into the email body. +func sanitizeAndNormalizeHTMLBody(body string) string { + if body == "" { + return "" + } + lines := strings.Split(body, "\n") + var b strings.Builder + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString("

") + b.WriteString(html.EscapeString(line)) + b.WriteString("

") + } + return b.String() +} + // sanitizeEmailBody performs SMTP dot-stuffing to prevent email injection. // According to RFC 5321, if a line starts with a period, it must be doubled // to prevent premature termination of the SMTP DATA command. diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index ed9f7d7c..0f8aba5e 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "html" "net" "net/http" neturl "net/url" @@ -284,12 +283,24 @@ func (s *NotificationService) dispatchEmail(ctx context.Context, p models.Notifi safeTitle := sanitizeForEmail(title) safeMessage := sanitizeForEmail(message) subject := fmt.Sprintf("[Charon Alert] %s", safeTitle) - htmlBody := "

" + html.EscapeString(safeTitle) + "

" + html.EscapeString(safeMessage) + "

" + // Build a plain-text body; MailService will convert this to safe HTML and + // perform additional sanitization before sending. + var bodyBuilder strings.Builder + if safeTitle != "" { + bodyBuilder.WriteString(safeTitle) + } + if safeMessage != "" { + if bodyBuilder.Len() > 0 { + bodyBuilder.WriteString("\n\n") + } + bodyBuilder.WriteString(safeMessage) + } + plainBody := bodyBuilder.String() timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, htmlBody); err != nil { + if err := s.mailService.SendEmail(timeoutCtx, recipients, subject, plainBody); err != nil { logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send email notification") } }