Skip to content

Commit 58ec417

Browse files
committed
fix email injection issue
1 parent de87add commit 58ec417

2 files changed

Lines changed: 38 additions & 14 deletions

File tree

internal/email/service.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,42 @@ import (
99
"crypto/tls"
1010
"fmt"
1111
"io"
12+
"net/mail"
1213
"net/smtp"
1314
"regexp"
1415
"strings"
1516
"sync"
1617
"time"
1718
)
1819

19-
// headerInjectionPattern matches CRLF sequences that could be used for header injection
20-
var headerInjectionPattern = regexp.MustCompile(`[\r\n]`)
20+
// headerInjectionPattern matches control characters that could be used for header injection
21+
var headerInjectionPattern = regexp.MustCompile(`[\x00-\x1F\x7F]`)
2122

22-
// bodyControlCharsPattern matches control characters (CR, LF, null) that enable content smuggling
23-
var bodyControlCharsPattern = regexp.MustCompile(`[\r\n\x00]`)
23+
// bodyControlCharsPattern matches null bytes and other unsafe control characters
24+
// while allowing tab, regular newlines, and carriage returns
25+
var bodyControlCharsPattern = regexp.MustCompile(`[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]`)
2426

25-
// sanitizeHeader removes CRLF characters to prevent email header injection
27+
// sanitizeHeader removes control characters to prevent email header injection
2628
func sanitizeHeader(input string) string {
2729
return headerInjectionPattern.ReplaceAllString(input, "")
2830
}
2931

30-
// sanitizeBody removes control characters (CR/LF/null) that could enable header/content smuggling
31-
// while preserving normal message content
32+
// sanitizeEmailAddress uses net/mail to ensure the address is safe and correctly formatted
33+
func sanitizeEmailAddress(input string) string {
34+
addr, err := mail.ParseAddress(input)
35+
if err != nil {
36+
// If it's not a valid RFC 5322 address, fallback to a strict header sanitization
37+
// but ideally this should have been caught by IsValidEmail
38+
return sanitizeHeader(input)
39+
}
40+
if addr.Name == "" {
41+
return addr.Address
42+
}
43+
return addr.String()
44+
}
45+
46+
// sanitizeBody removes null bytes and other control characters that could enable smuggling
47+
// while preserving normal message content including newlines
3248
func sanitizeBody(input string) string {
3349
return bodyControlCharsPattern.ReplaceAllString(input, "")
3450
}
@@ -88,10 +104,10 @@ func (es *EmailService) SendEmail(to []string, subject, body string, isHTML bool
88104
sanitizedBody := sanitizeBody(body)
89105
sanitizedTo := make([]string, len(to))
90106
for i, addr := range to {
91-
sanitizedTo[i] = sanitizeHeader(addr)
107+
sanitizedTo[i] = sanitizeEmailAddress(addr)
92108
}
93109
// Sanitize FromEmail before SMTP envelope usage
94-
sanitizedFrom := sanitizeHeader(es.FromEmail)
110+
sanitizedFrom := sanitizeEmailAddress(es.FromEmail)
95111
if sanitizedFrom == "" {
96112
return fmt.Errorf("invalid or empty sender email after sanitization")
97113
}

tests/security_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,14 @@ func TestEmailInjection_HeaderInjectionPrevention(t *testing.T) {
114114
if err != nil {
115115
t.Fatalf("SendEmail failed: %v", err)
116116
}
117+
// After sanitization, net/mail will typically remove the invalid parts or quote them
118+
// In our case, it should at least remove the CRLF
117119
if strings.Contains(capturedMessage, "\r") || strings.Contains(capturedMessage, "\n") {
118120
t.Errorf("CRLF characters not sanitized from recipient: %s", capturedMessage)
119121
}
122+
// Note: net/mail.ParseAddress might return "<victim@example.comBcc:attacker@evil.com>"
123+
// because it strips control characters via sanitizeHeader fallback if parsing fails,
124+
// or it might just error. Our implementation currently falls back to sanitizeHeader if ParseAddress fails.
120125
}
121126

122127
// TestEmailInjection_BodyControlCharactersPrevention tests that control characters in body are removed
@@ -136,9 +141,9 @@ func TestEmailInjection_BodyControlCharactersPrevention(t *testing.T) {
136141
t.Fatalf("SendEmail failed: %v", err)
137142
}
138143

139-
// Body should have control characters removed
140-
if strings.Contains(capturedBody, "\r") || strings.Contains(capturedBody, "\n") {
141-
t.Errorf("CRLF characters not sanitized from body: %q", capturedBody)
144+
// Body should have null bytes removed, but CRLF should REMAIN
145+
if !strings.Contains(capturedBody, "\r\n") {
146+
t.Errorf("CRLF characters SHOULD remain in body for multi-line support: %q", capturedBody)
142147
}
143148
if strings.Contains(capturedBody, "\x00") {
144149
t.Errorf("Null bytes not sanitized from body: %q", capturedBody)
@@ -255,8 +260,11 @@ func TestEmailInjection_TransactionalEmailSanitization(t *testing.T) {
255260
if strings.Contains(capturedSubject, "\r") || strings.Contains(capturedSubject, "\n") {
256261
t.Errorf("Subject CRLF not sanitized: %q", capturedSubject)
257262
}
258-
if strings.Contains(capturedBody, "\r") || strings.Contains(capturedBody, "\n") || strings.Contains(capturedBody, "\x00") {
259-
t.Errorf("Body control characters not sanitized: %q", capturedBody)
263+
if !strings.Contains(capturedBody, "\r\n") {
264+
t.Errorf("Body CRLF SHOULD remain for multi-line support: %q", capturedBody)
265+
}
266+
if strings.Contains(capturedBody, "\x00") {
267+
t.Errorf("Body null bytes not sanitized: %q", capturedBody)
260268
}
261269
}
262270

0 commit comments

Comments
 (0)