diff --git a/pipe.go b/pipe.go index 02aaa30..833af00 100644 --- a/pipe.go +++ b/pipe.go @@ -46,11 +46,14 @@ type Direction string type Elapse int const ( - mailFromPrefix string = "MAIL FROM:<" - rcptToPrefix string = "RCPT TO:<" - mailRegex string = `[A-z0-9.!#$%&'*+\-/=?^_\{|}~]{1,64}@[A-z0-9.\-]{1,255}` - crlf string = "\r\n" - mailHeaderEnd string = crlf + crlf + mailFromPrefix string = "MAIL FROM:<" + rcptToPrefix string = "RCPT TO:<" + mailRegex string = `(?i)MAIL\s+FROM\s*:\s*<[A-z0-9.!#$%&'*+\-/=?^_\{|}~]{1,64}@[A-z0-9.\-]{1,255}>` + rcptToRegex string = `(?i)RCPT\s+TO\s*:\s*<[A-z0-9.!#$%&'*+\-/=?^_\{|}~]{1,64}@[A-z0-9.\-]{1,255}>` + mailRegexStrict string = `(?i)MAIL FROM:<[A-z0-9.!#$%&'*+\-/=?^_\{|}~]{1,64}@[A-z0-9.\-]{1,255}>` + rcptToRegexStrict string = `(?i)RCPT TO:<[A-z0-9.!#$%&'*+\-/=?^_\{|}~]{1,64}@[A-z0-9.\-]{1,255}>` + crlf string = "\r\n" + mailHeaderEnd string = crlf + crlf srcToPxy Direction = ">|" pxyToDst Direction = "|>" @@ -70,14 +73,54 @@ const ( ) var ( - mailFromRegex = regexp.MustCompile(mailFromPrefix + mailRegex) - mailToRegex = regexp.MustCompile(rcptToPrefix + mailRegex) + mailFromRegex = regexp.MustCompile(mailRegex) + mailToRegex = regexp.MustCompile(rcptToRegex) + mailFromRegexStrict = regexp.MustCompile(mailRegexStrict) + mailToRegexStrict = regexp.MustCompile(rcptToRegexStrict) ) func (e Elapse) String() string { return fmt.Sprintf("%d msec", e) } +// toLower converts ASCII byte to lowercase +func toLower(b byte) byte { + if b >= 'A' && b <= 'Z' { + return b + ('a' - 'A') + } + return b +} + +// containsFold performs case-insensitive bytes.Contains for ASCII +func containsFold(s, substr []byte) bool { + if len(substr) == 0 { + return true + } + if len(substr) > len(s) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if toLower(s[i+j]) != toLower(substr[j]) { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// isRFCCompliant checks if the matched command strictly follows RFC 5321 syntax +// RFC 5321 Section 3.3: "spaces are not permitted on either side of the colon +// following FROM in the MAIL command or TO in the RCPT command" +func isRFCCompliant(match []byte, strictRegex *regexp.Regexp) bool { + return strictRegex.Match(match) +} + func (p *Pipe) mediateOnUpstream(b []byte, i int) ([]byte, int, bool) { data := b[0:i] @@ -181,24 +224,61 @@ func (p *Pipe) Do() { } func (p *Pipe) setSenderServerName(b []byte) { - if bytes.Contains(b, []byte("HELO")) { - p.sServerName = bytes.TrimSpace(bytes.Replace(b, []byte("HELO"), []byte(""), 1)) + if containsFold(b, []byte("HELO ")) { + // Find the position of HELO (case-insensitive) and extract the hostname + upper := bytes.ToUpper(b) + idx := bytes.Index(upper, []byte("HELO ")) + if idx >= 0 { + p.sServerName = bytes.TrimSpace(b[idx+5:]) + } } - if bytes.Contains(b, []byte("EHLO")) { - p.sServerName = bytes.TrimSpace(bytes.Replace(b, []byte("EHLO"), []byte(""), 1)) + if containsFold(b, []byte("EHLO ")) { + // Find the position of EHLO (case-insensitive) and extract the hostname + upper := bytes.ToUpper(b) + idx := bytes.Index(upper, []byte("EHLO ")) + if idx >= 0 { + p.sServerName = bytes.TrimSpace(b[idx+5:]) + } } } func (p *Pipe) setSenderMailAddress(b []byte) { - if bytes.Contains(b, []byte(mailFromPrefix)) { - p.sMailAddr = bytes.Replace(mailFromRegex.Find(b), []byte(mailFromPrefix), []byte(""), 1) + match := mailFromRegex.Find(b) + if match != nil { + // Extract email address from "MAIL FROM:" (case-insensitive, relaxed spacing) + // Find the position of '<' and '>' + start := bytes.IndexByte(match, '<') + end := bytes.IndexByte(match, '>') + if start >= 0 && end > start { + p.sMailAddr = match[start+1 : end] + + // Check RFC 5321 compliance + if !isRFCCompliant(match, mailFromRegexStrict) { + go p.afterCommHook([]byte(fmt.Sprintf("RFC 5321 violation: %q (spaces not permitted around colon)", match)), onPxy) + } + } } } func (p *Pipe) setReceiverMailAddressAndServerName(b []byte) { - if bytes.Contains(b, []byte(rcptToPrefix)) { - p.rMailAddr = bytes.Replace(mailToRegex.Find(b), []byte(rcptToPrefix), []byte(""), 1) - p.rServerName = bytes.Split(p.rMailAddr, []byte("@"))[1] + match := mailToRegex.Find(b) + if match != nil { + // Extract email address from "RCPT TO:" (case-insensitive, relaxed spacing) + // Find the position of '<' and '>' + start := bytes.IndexByte(match, '<') + end := bytes.IndexByte(match, '>') + if start >= 0 && end > start { + p.rMailAddr = match[start+1 : end] + parts := bytes.Split(p.rMailAddr, []byte("@")) + if len(parts) == 2 { + p.rServerName = parts[1] + } + + // Check RFC 5321 compliance + if !isRFCCompliant(match, mailToRegexStrict) { + go p.afterCommHook([]byte(fmt.Sprintf("RFC 5321 violation: %q (spaces not permitted around colon)", match)), onPxy) + } + } } } diff --git a/pipe_test.go b/pipe_test.go index 2363a40..a9e0aaa 100644 --- a/pipe_test.go +++ b/pipe_test.go @@ -18,6 +18,26 @@ func TestSetSenderServerName(t *testing.T) { arg: []byte("HELO mx.example.local\r\n"), expectSenderServer: []byte("mx.example.local"), }, + { + // Case-insensitive: lowercase + arg: []byte("ehlo mx.example.local\r\n"), + expectSenderServer: []byte("mx.example.local"), + }, + { + // Case-insensitive: lowercase + arg: []byte("helo mx.example.local\r\n"), + expectSenderServer: []byte("mx.example.local"), + }, + { + // Case-insensitive: mixed case + arg: []byte("Ehlo mx.example.local\r\n"), + expectSenderServer: []byte("mx.example.local"), + }, + { + // Case-insensitive: mixed case + arg: []byte("Helo mx.example.local\r\n"), + expectSenderServer: []byte("mx.example.local"), + }, } for _, v := range tests { pipe := &Pipe{afterCommHook: func(b Data, to Direction) {}} @@ -47,6 +67,16 @@ func TestSetSenderMailAddress(t *testing.T) { arg: []byte("MAIL FROM: SIZE=4095\r\nRCPT TO: ORCPT=rfc822;bob@example.local\r\nDATA\r\n"), expectSenderAddr: []byte("bob@example.local"), }, + { + // Case-insensitive: lowercase + arg: []byte("mail from: SIZE=4095\r\n"), + expectSenderAddr: []byte("alice@example.test"), + }, + { + // Case-insensitive: mixed case + arg: []byte("Mail From: SIZE=4095\r\n"), + expectSenderAddr: []byte("charlie@example.net"), + }, } for _, v := range tests { pipe := &Pipe{afterCommHook: func(b Data, to Direction) {}} @@ -74,6 +104,18 @@ func TestSetReceiverMailAddressAndServerName(t *testing.T) { expectReceiverServer: []byte("example.com"), expectReceiverAddr: []byte("alice@example.com"), }, + { + // Case-insensitive: lowercase + arg: []byte("rcpt to:\r\n"), + expectReceiverServer: []byte("example.org"), + expectReceiverAddr: []byte("bob@example.org"), + }, + { + // Case-insensitive: mixed case + arg: []byte("Rcpt To:\r\n"), + expectReceiverServer: []byte("example.net"), + expectReceiverAddr: []byte("charlie@example.net"), + }, } for _, v := range tests { pipe := &Pipe{afterCommHook: func(b Data, to Direction) {}} diff --git a/rfc_violation_test.go b/rfc_violation_test.go new file mode 100644 index 0000000..0aabe2c --- /dev/null +++ b/rfc_violation_test.go @@ -0,0 +1,222 @@ +package warp + +import ( + "bytes" + "testing" + "time" +) + +// TestRFCViolation tests behavior with RFC 5321 violating SMTP commands and email addresses +// References: +// - RFC 5321 Section 2.4: Command verbs are case-insensitive +// - RFC 5321 Section 3.3: "spaces are not permitted on either side of the colon" +// - https://www.docomo.ne.jp/service/docomo_mail/rfc_add/ +// - https://www.sonoko.co.jp/user_data/oshirase10.php +// +// The proxy accepts RFC-violating commands to collect metadata, but logs warnings for spacing violations. +func TestRFCViolation(t *testing.T) { + tests := []struct { + name string + command []byte + expectAddr []byte + expectServer []byte + shouldMatch bool + expectWarning bool + description string + }{ + // === RFC Compliant Cases === + { + name: "RFC compliant: MAIL FROM:
", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("alice@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Standard RFC-compliant MAIL FROM", + }, + { + name: "RFC compliant: RCPT TO:
", + command: []byte("RCPT TO:\r\n"), + expectAddr: []byte("bob@example.com"), + expectServer: []byte("example.com"), + shouldMatch: true, + expectWarning: false, + description: "Standard RFC-compliant RCPT TO", + }, + + // === RFC Violation: Command Spacing === + { + name: "RFC violation: MAIL FROM:
(space after colon)", + command: []byte("MAIL FROM: \r\n"), + expectAddr: []byte("alice@example.com"), + shouldMatch: true, + expectWarning: true, + description: "Space after colon violates RFC 5321 Section 3.3", + }, + { + name: "RFC violation: MAIL FROM :
(spaces around colon)", + command: []byte("MAIL FROM : \r\n"), + expectAddr: []byte("alice@example.com"), + shouldMatch: true, + expectWarning: true, + description: "Spaces before and after colon", + }, + { + name: "RFC violation: MAIL FROM:
(double space)", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("alice@example.com"), + shouldMatch: true, + expectWarning: true, + description: "Double space between MAIL and FROM", + }, + { + name: "RFC violation: RCPT TO:
(space after colon)", + command: []byte("RCPT TO: \r\n"), + expectAddr: []byte("bob@example.com"), + expectServer: []byte("example.com"), + shouldMatch: true, + expectWarning: true, + description: "Space after colon in RCPT TO", + }, + + // === RFC Violation: Email Address Local Part (Carrier Patterns) === + { + name: "Carrier pattern: consecutive dots", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("user..name@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Two consecutive dots in local part", + }, + { + name: "Carrier pattern: triple consecutive dots", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("user...name@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Three consecutive dots in local part", + }, + { + name: "Carrier pattern: dot before @", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("username.@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Dot immediately before @ symbol", + }, + { + name: "Carrier pattern: hyphen at start", + command: []byte("MAIL FROM:<-username@example.com>\r\n"), + expectAddr: []byte("-username@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Hyphen at the start of local part", + }, + { + name: "Carrier pattern: dot at start", + command: []byte("MAIL FROM:<.username@example.com>\r\n"), + expectAddr: []byte(".username@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Dot at the start of local part", + }, + { + name: "Carrier pattern: consecutive hyphens", + command: []byte("MAIL FROM:\r\n"), + expectAddr: []byte("user--name@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Two consecutive hyphens in local part", + }, + { + name: "Carrier pattern: multiple violations", + command: []byte("MAIL FROM:<-user..name.@example.com>\r\n"), + expectAddr: []byte("-user..name.@example.com"), + shouldMatch: true, + expectWarning: false, + description: "Hyphen at start, consecutive dots, dot before @", + }, + + // === RCPT TO with Carrier Patterns === + { + name: "RCPT TO: carrier pattern consecutive dots", + command: []byte("RCPT TO:\r\n"), + expectAddr: []byte("user..name@example.com"), + expectServer: []byte("example.com"), + shouldMatch: true, + expectWarning: false, + description: "Consecutive dots in RCPT TO", + }, + { + name: "RCPT TO: carrier pattern hyphen at start", + command: []byte("RCPT TO:<-username@example.com>\r\n"), + expectAddr: []byte("-username@example.com"), + expectServer: []byte("example.com"), + shouldMatch: true, + expectWarning: false, + description: "Hyphen at start in RCPT TO", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loggedMsg := make(chan []byte, 1) + pipe := &Pipe{afterCommHook: func(b Data, to Direction) { + // Send logged message to channel (non-blocking) + select { + case loggedMsg <- b: + default: + } + }} + + // Determine command type and execute appropriate function + isRCPT := len(tt.expectServer) > 0 + if isRCPT { + // Test RCPT TO + pipe.setReceiverMailAddressAndServerName(tt.command) + if tt.shouldMatch { + if string(tt.expectAddr) != string(pipe.rMailAddr) { + t.Errorf("%s: expected address %q, got %q", tt.description, tt.expectAddr, pipe.rMailAddr) + } + if string(tt.expectServer) != string(pipe.rServerName) { + t.Errorf("%s: expected server %q, got %q", tt.description, tt.expectServer, pipe.rServerName) + } + } else { + if len(pipe.rMailAddr) > 0 { + t.Errorf("%s: expected no match, but got address %q", tt.description, pipe.rMailAddr) + } + } + } else { + // Test MAIL FROM + pipe.setSenderMailAddress(tt.command) + if tt.shouldMatch { + if string(tt.expectAddr) != string(pipe.sMailAddr) { + t.Errorf("%s: expected address %q, got %q", tt.description, tt.expectAddr, pipe.sMailAddr) + } + } else { + if len(pipe.sMailAddr) > 0 { + t.Errorf("%s: expected no match, but got address %q", tt.description, pipe.sMailAddr) + } + } + } + + // Wait for goroutine to log (with timeout) + var warningLogged bool + select { + case msg := <-loggedMsg: + if bytes.Contains(msg, []byte("RFC 5321 violation")) { + warningLogged = true + } + case <-time.After(50 * time.Millisecond): + // Timeout - no message received + } + + // Check if RFC violation warning was logged as expected + if tt.expectWarning && !warningLogged { + t.Errorf("%s: expected RFC violation warning to be logged, but none was found", tt.description) + } + if !tt.expectWarning && warningLogged { + t.Errorf("%s: unexpected RFC violation warning logged", tt.description) + } + }) + } +}