Files
certctl/internal/connector/notifier/email/email.go
T
shankar0123 b9478d064c security(email): sanitize body fields against content injection (CodeQL #11, CWE-640)
CodeQL alert #11 (go/email-injection, CWE-640 / OWASP Content Spoofing)
flagged the wc.Write(message) sink at internal/connector/notifier/email/
email.go:208 because attacker-controllable fields flow into the email
body unchecked.

Threat model:
  Headers (From, To, Subject) were already protected by
  validation.ValidateHeaderValue (CWE-113 SMTP header injection,
  closed in commit 9e957c3). The remaining gap was the body.
  An attacker controls multiple fields that surface to the body of
  alert/event notifications:
    - alert.Subject, alert.Message
    - event.Subject, event.Body, *event.CertificateID
    - alert.Metadata + event.Metadata key/value pairs
  These can carry CR/LF (forged 'Reply-To: attacker@evil.com' inside
  the body that recipients skim), NUL bytes (RFC 5321 4.5.2 violation
  that some MTAs truncate at), bidi-override Unicode (visually-
  spoofable URLs), zero-width / invisible Unicode (phishing), or
  malformed UTF-8 (Go emits U+FFFD which becomes a glyph in mail
  clients).

  The HTML email path (digest service) already uses html/template
  upstream and is safe via contextual auto-escape. This commit
  closes the plaintext path.

Fix:
  internal/validation/headers.go gains SanitizeEmailBodyValue —
  a sanitizer that NEVER errors (the right contract for body
  content; over-eager rejection drops operator notifications) and
  scrubs:
    - NUL bytes (stripped entirely)
    - bare CR / LF (replaced with space — single fields should never
      carry their own line breaks; the surrounding template handles
      legitimate CRLFs)
    - C0 control chars < 0x20 except TAB
    - DEL (0x7F) + C1 control chars (0x80-0x9F)
    - U+FFFD (defense in depth: malformed UTF-8 -> Go emits this;
      strip so attacker-planted invalid bytes don't survive as an
      arbitrary glyph)
    - Bidi-override Unicode (U+202A..U+202E, U+2066..U+2069)
    - Zero-width / invisible Unicode (U+200B..U+200D, U+2060..U+2063,
      U+FEFF, U+180E)
    - Catch-all unicode.IsControl for anything not enumerated above
  Codepoint table uses numeric ranges rather than rune-literal switch
  cases — Go source rejects literal invisible characters (BOM U+FEFF)
  mid-file, so the table compares against numeric values.

  internal/connector/notifier/email/email.go applies the sanitizer
  at every interpolation site:
    - formatAlertBody: alert.ID/Type/Severity/Subject/Message
      (CreatedAt is time.Time -> RFC3339, structural, not sanitized)
    - formatEventBody: event.ID/Type/Subject/Body, *CertificateID
      (CreatedAt structural, not sanitized)
    - formatMetadata: both keys and values
  The sendEmail / formatEmailMessage call sites continue to validate
  headers (From / To / Subject) via the existing ValidateHeaderValue
  fail-closed gate; the new sanitizer is body-side only.

Tests (internal/validation/headers_test.go):
  TestSanitizeEmailBodyValue_PreservesSafeInput
    Pin: ordinary ASCII, UTF-8 multibyte (résumé / 日本語 / مرحبا),
    tabs, common cert DNs, URLs all flow through unchanged.
  TestSanitizeEmailBodyValue_StripsControlChars
    Table-driven across NUL, bare LF/CR, CRLF, BEL, backspace, DEL,
    C1 (U+0080 / U+009F), U+FFFD, TAB-preserve.
  TestSanitizeEmailBodyValue_StripsBidiOverride
    7 attacker payloads (RLO, LRO, LRI, zero-width space, ZWNJ, BOM,
    MVS) — each must produce a non-identity output.
  TestSanitizeEmailBodyValue_ContentSpoofingScenario
    The CodeQL example case: 'alert\r\nReply-To: attacker@evil.com\r\n
    Click https://evil.example.com/reset' — verify NO CR/LF survives.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/validation/...: ok 0.374s
  go test -short -count=1 ./internal/connector/notifier/email/...: ok 0.186s

Reference: https://github.com/certctl-io/certctl/security/code-scanning/11
Closes CodeQL alert #11 (go/email-injection).
2026-05-04 04:56:13 +00:00

465 lines
14 KiB
Go

package email
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/certctl-io/certctl/internal/connector/notifier"
"github.com/certctl-io/certctl/internal/validation"
)
// Config represents the email notifier configuration.
type Config struct {
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
UseTLS bool `json:"tls"`
}
// Connector implements the notifier.Connector interface for email notifications.
// It sends alert and event notifications via SMTP.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new email notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the SMTP server is reachable and credentials are valid.
// It attempts to connect to the SMTP server to verify connectivity.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid email config: %w", err)
}
if cfg.SMTPHost == "" || cfg.SMTPPort == 0 || cfg.FromAddress == "" {
return fmt.Errorf("email smtp_host, smtp_port, and from_address are required")
}
c.logger.Info("validating email configuration",
"smtp_host", cfg.SMTPHost,
"smtp_port", cfg.SMTPPort)
// Test SMTP connectivity with timeout
addr := net.JoinHostPort(cfg.SMTPHost, strconv.Itoa(cfg.SMTPPort))
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to reach SMTP server %s: %w", addr, err)
}
defer conn.Close()
c.config = &cfg
c.logger.Info("email configuration validated")
return nil
}
// SendAlert sends an alert notification via SMTP.
// It formats the alert as an email message and sends it to the recipient.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending email alert",
"alert_id", alert.ID,
"severity", alert.Severity,
"recipient", alert.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Subject)
body := c.formatAlertBody(alert)
// Send email
if err := c.sendEmail(ctx, alert.Recipient, subject, body); err != nil {
c.logger.Error("failed to send alert email",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert email: %w", err)
}
c.logger.Info("alert email sent successfully",
"alert_id", alert.ID,
"recipient", alert.Recipient)
return nil
}
// SendEvent sends an event notification via SMTP.
// It formats the event as an email message and sends it to the recipient.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending email event",
"event_id", event.ID,
"event_type", event.Type,
"recipient", event.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[Event] %s", event.Subject)
body := c.formatEventBody(event)
// Send email
if err := c.sendEmail(ctx, event.Recipient, subject, body); err != nil {
c.logger.Error("failed to send event email",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event email: %w", err)
}
c.logger.Info("event email sent successfully",
"event_id", event.ID,
"recipient", event.Recipient)
return nil
}
// sendEmail sends an email message using the configured SMTP server.
// It handles both TLS and plain authentication modes.
//
// Header values (From, To, Subject) are validated up-front to reject CR, LF,
// and NUL characters. This blocks SMTP header injection (CWE-113) and also
// prevents injection into the SMTP envelope commands MAIL FROM and RCPT TO,
// since net/smtp does not sanitize those inputs itself.
func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) error {
if err := validation.ValidateHeaderValue("From", c.config.FromAddress); err != nil {
return fmt.Errorf("invalid sender: %w", err)
}
if err := validation.ValidateHeaderValue("To", to); err != nil {
return fmt.Errorf("invalid recipient: %w", err)
}
if err := validation.ValidateHeaderValue("Subject", subject); err != nil {
return fmt.Errorf("invalid subject: %w", err)
}
addr := net.JoinHostPort(c.config.SMTPHost, strconv.Itoa(c.config.SMTPPort))
// Connect to SMTP server
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
// Connect with TLS
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
// Connect without TLS
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Authenticate if credentials provided
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
// Send email
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// Format and write email headers and body. The format function
// re-validates header values as defense-in-depth; the early-return
// above should have already caught any injection attempt.
message, err := c.formatEmailMessage(c.config.FromAddress, to, subject, body)
if err != nil {
return fmt.Errorf("failed to format message: %w", err)
}
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// sendHTMLEmail sends an HTML email message using the configured SMTP server.
// Used by the digest service for rich HTML digest emails.
//
// Header values (From, To, Subject) are validated up-front to reject CR, LF,
// and NUL characters. This blocks SMTP header injection (CWE-113) and also
// prevents injection into the SMTP envelope commands MAIL FROM and RCPT TO,
// since net/smtp does not sanitize those inputs itself.
func (c *Connector) sendHTMLEmail(ctx context.Context, to, subject, htmlBody string) error {
if err := validation.ValidateHeaderValue("From", c.config.FromAddress); err != nil {
return fmt.Errorf("invalid sender: %w", err)
}
if err := validation.ValidateHeaderValue("To", to); err != nil {
return fmt.Errorf("invalid recipient: %w", err)
}
if err := validation.ValidateHeaderValue("Subject", subject); err != nil {
return fmt.Errorf("invalid subject: %w", err)
}
addr := net.JoinHostPort(c.config.SMTPHost, strconv.Itoa(c.config.SMTPPort))
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// The format function re-validates header values as defense-in-depth;
// the early-return above should have already caught any injection attempt.
message, err := c.formatHTMLEmailMessage(c.config.FromAddress, to, subject, htmlBody)
if err != nil {
return fmt.Errorf("failed to format message: %w", err)
}
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// formatEmailMessage formats an email message with standard headers.
// It rejects any header value containing CR, LF, or NUL bytes to prevent
// SMTP header injection (CWE-113). See internal/validation.ValidateHeaderValue.
// The body is not validated — CR/LF in the body is legitimate content, and
// SMTP dot-stuffing / length framing are handled by net/smtp.
func (c *Connector) formatEmailMessage(from, to, subject, body string) ([]byte, error) {
if err := validation.ValidateHeaderValue("From", from); err != nil {
return nil, err
}
if err := validation.ValidateHeaderValue("To", to); err != nil {
return nil, err
}
if err := validation.ValidateHeaderValue("Subject", subject); err != nil {
return nil, err
}
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
body,
)
return []byte(message), nil
}
// formatHTMLEmailMessage formats an HTML email message with MIME headers.
// It rejects any header value containing CR, LF, or NUL bytes to prevent
// SMTP header injection (CWE-113). See internal/validation.ValidateHeaderValue.
// The HTML body is not validated at this layer — CR/LF in HTML content is
// legitimate, and SMTP dot-stuffing / length framing are handled by net/smtp.
func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) ([]byte, error) {
if err := validation.ValidateHeaderValue("From", from); err != nil {
return nil, err
}
if err := validation.ValidateHeaderValue("To", to); err != nil {
return nil, err
}
if err := validation.ValidateHeaderValue("Subject", subject); err != nil {
return nil, err
}
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
htmlBody,
)
return []byte(message), nil
}
// formatAlertBody formats an alert notification as email body text.
//
// CodeQL go/email-injection (CWE-640 / OWASP Content Spoofing) defense:
// every field interpolated into the body that may carry attacker-
// controlled content (alert.Subject, alert.Message, alert.Metadata
// values, alert.ID / Type / Severity which originate from the API
// surface) is routed through validation.SanitizeEmailBodyValue before
// formatting. The sanitizer strips NUL bytes (RFC 5321 §4.5.2 violation),
// bare CR/LF within a single field (forged header-boundary attempts),
// bidi-override Unicode (visually-spoofable URLs), zero-width / invisible
// codepoints, and C0/C1 control chars. CreatedAt is a time.Time —
// formatted via RFC3339; not user-controllable so unsanitized.
//
// Header values (From, To, Subject) are protected separately by
// validation.ValidateHeaderValue at sendEmail entry (CWE-113 SMTP header
// injection — see commit 9e957c3).
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(`
Certificate Alert Notification
================================
Alert ID: %s
Type: %s
Severity: %s
Created: %s
Subject: %s
Message:
%s
%s
`,
validation.SanitizeEmailBodyValue(alert.ID),
validation.SanitizeEmailBodyValue(alert.Type),
validation.SanitizeEmailBodyValue(alert.Severity),
alert.CreatedAt.Format(time.RFC3339),
validation.SanitizeEmailBodyValue(alert.Subject),
validation.SanitizeEmailBodyValue(alert.Message),
c.formatMetadata(alert.Metadata),
)
return body
}
// formatEventBody formats an event notification as email body text.
//
// Same CodeQL go/email-injection mitigation as formatAlertBody — every
// user-controllable interpolated field routes through
// validation.SanitizeEmailBodyValue. CreatedAt is unsanitized (time.Time
// → RFC3339 is structural, not user-controllable).
func (c *Connector) formatEventBody(event notifier.Event) string {
certInfo := ""
if event.CertificateID != nil {
certInfo = fmt.Sprintf("Certificate ID: %s\n", validation.SanitizeEmailBodyValue(*event.CertificateID))
}
body := fmt.Sprintf(`
Certificate Event Notification
================================
Event ID: %s
Type: %s
Created: %s
%sSubject: %s
Body:
%s
%s
`,
validation.SanitizeEmailBodyValue(event.ID),
validation.SanitizeEmailBodyValue(event.Type),
event.CreatedAt.Format(time.RFC3339),
certInfo,
validation.SanitizeEmailBodyValue(event.Subject),
validation.SanitizeEmailBodyValue(event.Body),
c.formatMetadata(event.Metadata),
)
return body
}
// formatMetadata formats metadata as a readable string.
//
// Both keys and values can carry attacker-controlled content (cert
// subject DN fragments, discovered cert metadata, owner/team labels —
// all originate from API surfaces an attacker may influence). Both are
// routed through validation.SanitizeEmailBodyValue. Closes the
// CodeQL go/email-injection finding alongside formatAlertBody +
// formatEventBody.
func (c *Connector) formatMetadata(metadata map[string]string) string {
if len(metadata) == 0 {
return ""
}
metadataStr := "\nMetadata:\n"
for key, value := range metadata {
metadataStr += fmt.Sprintf(" %s: %s\n",
validation.SanitizeEmailBodyValue(key),
validation.SanitizeEmailBodyValue(value),
)
}
return metadataStr
}