e332403222
- Validate all recipients with net/mail.ParseAddress; reject CR/LF. - RFC 2047 (Q-encoding) for Subject and FromName so CRLF and non-ASCII cannot break out of headers. - Honor use_tls: implicit TLS on port 465 with certificate verification; otherwise document the opportunistic-STARTTLS path. - Table-driven tests for validation, injection neutralization, and multipart building. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
8.6 KiB
Go
293 lines
8.6 KiB
Go
package notifier
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/igodwin/notifier/internal/domain"
|
|
)
|
|
|
|
// TestValidateRecipient covers the recipient validation helper used by Send() to reject
|
|
// header-injection attempts and syntactically invalid addresses before a message is built
|
|
// or a network connection is opened.
|
|
func TestValidateRecipient(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
recipient string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid simple address",
|
|
recipient: "user@example.com",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid address with display name",
|
|
recipient: "Jane Doe <jane@example.com>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing at sign",
|
|
recipient: "not-an-email",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
recipient: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "CRLF header injection attempt",
|
|
recipient: "user@example.com\r\nBcc: attacker@evil.com",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "bare LF header injection attempt",
|
|
recipient: "user@example.com\nX-Injected: true",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "bare CR header injection attempt",
|
|
recipient: "user@example.com\rX-Injected: true",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateRecipient(tt.recipient)
|
|
if tt.wantErr && err == nil {
|
|
t.Fatalf("validateRecipient(%q) = nil, want error", tt.recipient)
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Fatalf("validateRecipient(%q) = %v, want nil", tt.recipient, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSendRejectsInvalidRecipientsWithoutNetworkAccess verifies that Send() rejects invalid
|
|
// or CRLF-laden recipients during validation, before ever attempting to dial the SMTP server.
|
|
// The configured host is deliberately non-routable so the test would hang or fail on a real
|
|
// dial attempt if validation didn't short-circuit first.
|
|
func TestSendRejectsInvalidRecipientsWithoutNetworkAccess(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
recipients []string
|
|
cc []string
|
|
bcc []string
|
|
}{
|
|
{
|
|
name: "invalid To address",
|
|
recipients: []string{"not-an-email"},
|
|
},
|
|
{
|
|
name: "CRLF injection in To address",
|
|
recipients: []string{"user@example.com\r\nBcc: attacker@evil.com"},
|
|
},
|
|
{
|
|
name: "CRLF injection in CC address",
|
|
recipients: []string{"user@example.com"},
|
|
cc: []string{"cc@example.com\r\nX-Injected: true"},
|
|
},
|
|
{
|
|
name: "CRLF injection in BCC address",
|
|
recipients: []string{"user@example.com"},
|
|
bcc: []string{"bcc@example.com\r\nX-Injected: true"},
|
|
},
|
|
}
|
|
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "invalid.invalid", // non-routable placeholder; must never be dialed
|
|
Port: 587,
|
|
From: "sender@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
notification := &domain.Notification{
|
|
ID: "test-id",
|
|
Type: domain.TypeEmail,
|
|
Subject: "Test Subject",
|
|
Body: "Test Body",
|
|
Recipients: tt.recipients,
|
|
CC: tt.cc,
|
|
BCC: tt.bcc,
|
|
}
|
|
|
|
result, err := notifier.Send(t.Context(), notification)
|
|
if err == nil {
|
|
t.Fatalf("Send() error = nil, want validation error")
|
|
}
|
|
if result == nil || result.Success {
|
|
t.Fatalf("Send() result = %+v, want Success=false", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildMessageNeutralizesSubjectCRLF ensures a CRLF-laden Subject cannot smuggle a new
|
|
// header into the raw message: the injected header line must not appear verbatim.
|
|
func TestBuildMessageNeutralizesSubjectCRLF(t *testing.T) {
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
From: "sender@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "test-id",
|
|
Type: domain.TypeEmail,
|
|
Subject: "Hello\r\nX-Injected: evil",
|
|
Body: "Test Body",
|
|
Recipients: []string{"user@example.com"},
|
|
}
|
|
|
|
message := notifier.buildMessage(notification)
|
|
|
|
if strings.Contains(message, "\r\nX-Injected:") {
|
|
t.Fatalf("built message contains injected header line:\n%s", message)
|
|
}
|
|
if strings.Contains(message, "X-Injected: evil") {
|
|
t.Fatalf("built message contains raw injected header value:\n%s", message)
|
|
}
|
|
|
|
// The Subject header line must still be present, just RFC 2047 encoded.
|
|
if !strings.Contains(message, "Subject: =?utf-8?q?") {
|
|
t.Fatalf("expected RFC 2047 encoded Subject header, got message:\n%s", message)
|
|
}
|
|
}
|
|
|
|
// TestBuildMessageNeutralizesFromNameCRLF ensures a CRLF-laden FromName config value cannot
|
|
// inject an extra header into the From line.
|
|
func TestBuildMessageNeutralizesFromNameCRLF(t *testing.T) {
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
From: "sender@example.com",
|
|
FromName: "Evil\r\nX-Injected: true",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "test-id",
|
|
Type: domain.TypeEmail,
|
|
Subject: "Hello",
|
|
Body: "Test Body",
|
|
Recipients: []string{"user@example.com"},
|
|
}
|
|
|
|
message := notifier.buildMessage(notification)
|
|
|
|
if strings.Contains(message, "\r\nX-Injected:") {
|
|
t.Fatalf("built message contains injected header line from FromName:\n%s", message)
|
|
}
|
|
if strings.Contains(message, "X-Injected: true") {
|
|
t.Fatalf("built message contains raw injected FromName value:\n%s", message)
|
|
}
|
|
|
|
if !strings.Contains(message, "From: =?utf-8?q?") {
|
|
t.Fatalf("expected RFC 2047 encoded From display name, got message:\n%s", message)
|
|
}
|
|
}
|
|
|
|
// TestBuildMessagePlainSubjectUnchanged verifies that a benign ASCII subject is left
|
|
// unencoded (mime.QEncoding.Encode is a no-op for plain ASCII), preserving existing behavior.
|
|
func TestBuildMessagePlainSubjectUnchanged(t *testing.T) {
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
From: "sender@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "test-id",
|
|
Type: domain.TypeEmail,
|
|
Subject: "Plain Subject Line",
|
|
Body: "Test Body",
|
|
Recipients: []string{"user@example.com"},
|
|
}
|
|
|
|
message := notifier.buildMessage(notification)
|
|
|
|
if !strings.Contains(message, "Subject: Plain Subject Line\r\n") {
|
|
t.Fatalf("expected plain ASCII subject to be left unencoded, got message:\n%s", message)
|
|
}
|
|
}
|
|
|
|
// TestBuildMessageMultipartWithHTMLBody verifies that supplying HTMLBody still produces a
|
|
// correct multipart/alternative message with both text/plain and text/html parts.
|
|
func TestBuildMessageMultipartWithHTMLBody(t *testing.T) {
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "smtp.example.com",
|
|
Port: 587,
|
|
From: "sender@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "test-id",
|
|
Type: domain.TypeEmail,
|
|
Subject: "Multipart Test",
|
|
Body: "Plain text body",
|
|
HTMLBody: "<p>HTML body</p>",
|
|
Recipients: []string{"user@example.com"},
|
|
}
|
|
|
|
message := notifier.buildMessage(notification)
|
|
|
|
if !strings.Contains(message, "Content-Type: multipart/alternative; boundary=") {
|
|
t.Fatalf("expected multipart/alternative content type, got message:\n%s", message)
|
|
}
|
|
if !strings.Contains(message, "Content-Type: text/plain; charset=UTF-8") {
|
|
t.Fatalf("expected text/plain part, got message:\n%s", message)
|
|
}
|
|
if !strings.Contains(message, "Content-Type: text/html; charset=UTF-8") {
|
|
t.Fatalf("expected text/html part, got message:\n%s", message)
|
|
}
|
|
if !strings.Contains(message, "Plain text body") {
|
|
t.Fatalf("expected plain text body verbatim, got message:\n%s", message)
|
|
}
|
|
if !strings.Contains(message, "<p>HTML body</p>") {
|
|
t.Fatalf("expected HTML body verbatim, got message:\n%s", message)
|
|
}
|
|
|
|
// Ensure the message ends with a proper closing boundary.
|
|
if !strings.Contains(message, "--\r\n") {
|
|
t.Fatalf("expected closing MIME boundary, got message:\n%s", message)
|
|
}
|
|
}
|
|
|
|
// TestNewSMTPNotifierDefaultsAndUseTLS is a sanity check that UseTLS is stored on the config
|
|
// and that port defaulting still works as before, since Send() now branches on both.
|
|
func TestNewSMTPNotifierDefaultsAndUseTLS(t *testing.T) {
|
|
notifier, err := NewSMTPNotifier(&SMTPConfig{
|
|
Host: "smtp.example.com",
|
|
From: "sender@example.com",
|
|
UseTLS: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSMTPNotifier() error = %v", err)
|
|
}
|
|
|
|
if notifier.config.Port != 587 {
|
|
t.Fatalf("expected default port 587, got %d", notifier.config.Port)
|
|
}
|
|
if !notifier.config.UseTLS {
|
|
t.Fatalf("expected UseTLS to be preserved as true")
|
|
}
|
|
}
|