mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 22:19:00 +00:00
9e957c3447
H-3 in certctl-audit-report.md: caller-supplied From/To/Subject were
interpolated directly into the SMTP DATA payload and handed to
client.Mail / client.Rcpt with no sanitization, allowing an attacker
who controls any of those values to inject extra headers (Bcc:,
Reply-To:), split the message body (CRLFCRLF), or tamper with the
SMTP envelope. CWE-113.
Fix:
- New package helper internal/validation.ValidateHeaderValue(field,
value). Rejects CR ("\r"), LF ("\n"), and NUL ("\x00") with an error
that names the offending field but does NOT echo the raw value,
so log readers cannot be attacked with injected content. Silent
stripping was considered and rejected: authentication-relevant
headers must fail visibly.
- Two-layer defense in internal/connector/notifier/email/email.go:
(1) primary guard at the top of sendEmail / sendHTMLEmail, which
blocks tampering of the SMTP envelope (client.Mail, client.Rcpt)
since net/smtp does not sanitize those arguments; and
(2) defense-in-depth guard inside formatEmailMessage /
formatHTMLEmailMessage, catching any future caller that
bypasses sendEmail. Both format functions now return an error.
- Body content is intentionally NOT validated — CR/LF in body is legal
RFC 5322 content and net/smtp handles dot-stuffing.
Tests:
- internal/validation/headers_test.go: 3 functions (AcceptsSafeInput,
RejectsControlCharacters, DefaultFieldName) covering plain ASCII,
UTF-8 multibyte, tabs, typical email addresses, CRLF injection,
lone CR, lone LF, NUL, CRLFCRLF body split, trailing CR, leading LF.
Each reject case asserts the field name IS in the error and the
raw offending value IS NOT (anti-log-injection).
- internal/connector/notifier/email/email_test.go: added
TestEmail_FormatEmailMessage_RejectsCRLFInjection and
TestEmail_FormatHTMLEmailMessage_RejectsCRLFInjection. Existing
format tests updated for the new (bytes, error) signature.
Wire-format invariants preserved:
- SMTP DATA headers still use CRLF separators and RFC 1123Z Date
(unchanged).
- Content-Type headers unchanged (text/plain for plain, text/html +
MIME-Version: 1.0 for HTML).
- No change to message encoding or transport.
Verification (Go 1.25.9 linux-arm64, parent 0750c5f):
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/validation/... ok
- go test -race ./internal/connector/notifier/email/... ok
- go test -race ./internal/connector/notifier/webhook/... ok
- Per-layer coverage gates all pass:
validation 95.1% (+0.7 vs baseline 94.4%)
email 39.7% (+1.4 vs baseline 38.3%)
service 67.8% (unchanged)
handler 78.6% (unchanged)
middleware 80.0% (unchanged)
domain 92.7% (unchanged)
- govulncheck ./... No vulnerabilities found
- golangci-lint run ./internal/validation/... ./internal/connector/notifier/email/...
0 issues
Operational note: SMTP sends that would previously deliver a
tampered message now fail fast at the notifier with a clear error.
Operators who were relying on header-injection-shaped inputs (there
should be none in practice — all callers are internal certctl code)
will see "failed to format message: <field> contains disallowed
control character" in logs.
Scope: H-3 only. H-4 (webhook SSRF) follows in a separate commit.
419 lines
12 KiB
Go
419 lines
12 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/smtp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/notifier"
|
|
"github.com/shankar0123/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.
|
|
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
|
|
`, alert.ID, alert.Type, alert.Severity, alert.CreatedAt.Format(time.RFC3339), alert.Subject, alert.Message, c.formatMetadata(alert.Metadata))
|
|
|
|
return body
|
|
}
|
|
|
|
// formatEventBody formats an event notification as email body text.
|
|
func (c *Connector) formatEventBody(event notifier.Event) string {
|
|
certInfo := ""
|
|
if event.CertificateID != nil {
|
|
certInfo = fmt.Sprintf("Certificate ID: %s\n", *event.CertificateID)
|
|
}
|
|
|
|
body := fmt.Sprintf(`
|
|
Certificate Event Notification
|
|
================================
|
|
|
|
Event ID: %s
|
|
Type: %s
|
|
Created: %s
|
|
|
|
%sSubject: %s
|
|
|
|
Body:
|
|
%s
|
|
|
|
%s
|
|
`, event.ID, event.Type, event.CreatedAt.Format(time.RFC3339), certInfo, event.Subject, event.Body, c.formatMetadata(event.Metadata))
|
|
|
|
return body
|
|
}
|
|
|
|
// formatMetadata formats metadata as a readable string.
|
|
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", key, value)
|
|
}
|
|
|
|
return metadataStr
|
|
}
|