diff --git a/internal/connector/notifier/email/email.go b/internal/connector/notifier/email/email.go index e93f2a7..81fa5c8 100644 --- a/internal/connector/notifier/email/email.go +++ b/internal/connector/notifier/email/email.go @@ -13,6 +13,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/notifier" + "github.com/shankar0123/certctl/internal/validation" ) // Config represents the email notifier configuration. @@ -123,7 +124,22 @@ func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error { // 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 @@ -182,8 +198,13 @@ func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) err } defer wc.Close() - // Format and write email headers and body - message := c.formatEmailMessage(c.config.FromAddress, to, subject, body) + // 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) } @@ -197,7 +218,22 @@ func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) err // 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 @@ -250,7 +286,12 @@ func (c *Connector) sendHTMLEmail(ctx context.Context, to, subject, htmlBody str } defer wc.Close() - message := c.formatHTMLEmailMessage(c.config.FromAddress, to, subject, htmlBody) + // 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) } @@ -263,7 +304,20 @@ func (c *Connector) sendHTMLEmail(ctx context.Context, to, subject, htmlBody str } // formatEmailMessage formats an email message with standard headers. -func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte { +// 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, @@ -272,11 +326,24 @@ func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte { time.Now().Format(time.RFC1123Z), body, ) - return []byte(message) + return []byte(message), nil } // formatHTMLEmailMessage formats an HTML email message with MIME headers. -func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) []byte { +// 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, @@ -285,7 +352,7 @@ func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) [ time.Now().Format(time.RFC1123Z), htmlBody, ) - return []byte(message) + return []byte(message), nil } // formatAlertBody formats an alert notification as email body text. diff --git a/internal/connector/notifier/email/email_test.go b/internal/connector/notifier/email/email_test.go index ef00fe3..cadf518 100644 --- a/internal/connector/notifier/email/email_test.go +++ b/internal/connector/notifier/email/email_test.go @@ -138,7 +138,10 @@ func TestEmail_FormatMessage_RFC822Headers(t *testing.T) { subject := "Test Subject" body := "Test Body" - message := conn.formatEmailMessage(from, to, subject, body) + message, err := conn.formatEmailMessage(from, to, subject, body) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } messageStr := string(message) if !strings.Contains(messageStr, "From: "+from) { @@ -177,7 +180,10 @@ func TestEmail_FormatHTMLEmailMessage_Headers(t *testing.T) { subject := "HTML Test" htmlBody := "
hi
", + ) + if err == nil { + t.Fatal("expected CRLF injection error, got nil") + } + if !strings.Contains(err.Error(), "Subject") { + t.Errorf("expected error to mention Subject field, got %q", err.Error()) + } +} + func TestEmail_FormatAlertBody(t *testing.T) { cfg := &Config{ SMTPHost: "smtp.example.com", diff --git a/internal/validation/headers.go b/internal/validation/headers.go new file mode 100644 index 0000000..123ce0c --- /dev/null +++ b/internal/validation/headers.go @@ -0,0 +1,36 @@ +package validation + +import ( + "fmt" + "strings" +) + +// ValidateHeaderValue rejects any value that contains characters capable of +// breaking out of a header line and injecting additional headers or body +// content. It guards against CRLF injection (CWE-113) in RFC 5322 message +// headers (SMTP, IMAP, etc.) and RFC 7230 HTTP headers alike. +// +// Disallowed characters: +// - Carriage return ("\r") +// - Line feed ("\n") +// - NUL ("\x00") +// +// The field name is included in the returned error solely for operator +// diagnostics; the offending value is not echoed back, so untrusted input +// does not leak into logs that render this error. +// +// Callers should invoke this on any string that will be interpolated into a +// header (From, To, Subject, Reply-To, custom X-* headers, etc.) before the +// headers are serialized. Values containing CR/LF/NUL MUST be rejected +// outright; silent stripping is inappropriate for authentication-relevant +// headers because it can mask malicious intent while still altering the +// message. +func ValidateHeaderValue(field, value string) error { + if field == "" { + field = "header" + } + if strings.ContainsAny(value, "\r\n\x00") { + return fmt.Errorf("%s contains disallowed control character (CR, LF, or NUL)", field) + } + return nil +} diff --git a/internal/validation/headers_test.go b/internal/validation/headers_test.go new file mode 100644 index 0000000..c4f3121 --- /dev/null +++ b/internal/validation/headers_test.go @@ -0,0 +1,70 @@ +package validation + +import ( + "strings" + "testing" +) + +func TestValidateHeaderValue_AcceptsSafeInput(t *testing.T) { + tests := []struct { + name string + field string + value string + }{ + {"plain ASCII", "Subject", "Renewal reminder"}, + {"empty string", "Reply-To", ""}, + {"utf-8 multibyte", "Subject", "résumé — 日本語"}, + {"tabs and spaces permitted", "Subject", "a\tb c"}, + {"typical email address", "From", "alerts@example.com"}, + {"long Subject within limits", "Subject", strings.Repeat("x", 998)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateHeaderValue(tc.field, tc.value); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + } +} + +func TestValidateHeaderValue_RejectsControlCharacters(t *testing.T) { + tests := []struct { + name string + field string + value string + }{ + {"injected CRLF + header", "Subject", "hello\r\nBcc: attacker@example.com"}, + {"lone LF", "From", "alice@example.com\nBcc: x@y"}, + {"lone CR", "Subject", "hello\rworld"}, + {"NUL byte", "To", "bob@example.com\x00extra"}, + {"CRLFCRLF body injection", "Subject", "ping\r\n\r\nMalicious body"}, + {"CR at end", "Subject", "trailing\r"}, + {"LF at start", "Subject", "\nleading"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateHeaderValue(tc.field, tc.value) + if err == nil { + t.Fatalf("expected error rejecting control characters, got nil") + } + // Error must mention the field so operators can pinpoint the offender. + if !strings.Contains(err.Error(), tc.field) { + t.Errorf("expected error to mention field %q, got %q", tc.field, err.Error()) + } + // Error must NOT leak the raw value back into logs. + if strings.Contains(err.Error(), tc.value) { + t.Errorf("error leaks raw value; expected redaction: %q", err.Error()) + } + }) + } +} + +func TestValidateHeaderValue_DefaultFieldName(t *testing.T) { + err := ValidateHeaderValue("", "bad\r\nvalue") + if err == nil { + t.Fatal("expected error for CRLF input, got nil") + } + if !strings.Contains(err.Error(), "header") { + t.Errorf("expected default field name 'header' in error, got %q", err.Error()) + } +}