1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13 Regex::new(
15 &[
16 r"(?mi)^From\s*:.*\r?\n^From\s*:",
20 r"|^MIME-Version\s*:",
25 ]
30 .concat(),
31 )
32 .unwrap()
33});
34
35static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r"(?mi)^Bcc\s*:|^Cc\s*:").unwrap()
44});
45
46pub struct MailHeaderDetector;
47
48impl Detector for MailHeaderDetector {
49 fn name(&self) -> &'static str {
50 "mail_header"
51 }
52
53 fn detect(&self, input: &str) -> Option<DetectionResult> {
54 regex_detect(
55 std::slice::from_ref(&*STRONG_PATTERNS),
56 self.name(),
57 AttackCategory::Data,
58 Severity::Medium,
59 "Mail header injection detected",
60 input,
61 )
62 .or_else(|| {
63 regex_detect(
64 std::slice::from_ref(&*WEAK_PATTERNS),
65 self.name(),
66 AttackCategory::Data,
67 Severity::Low,
68 "Recipient header present (weak signal)",
69 input,
70 )
71 })
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn name_returns_attack_type() {
81 assert_eq!(MailHeaderDetector.name(), "mail_header");
82 }
83
84 #[test]
88 fn detects_injected_recipient_headers() {
89 for payload in [
90 "Bcc: victim@evil.com",
91 "Cc: victim@evil.com",
92 "bcc: lower@case.com",
93 ] {
94 let r = MailHeaderDetector
95 .detect(payload)
96 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
97 assert_eq!(r.attack_type, "mail_header");
98 assert_eq!(r.category, AttackCategory::Data);
99 assert_eq!(r.severity, Severity::Low);
100 assert!(
101 !r.matched_pattern.is_empty(),
102 "matched_pattern empty for {:?}",
103 payload
104 );
105 assert!(
106 r.offset <= payload.len(),
107 "offset out of range for {:?}",
108 payload
109 );
110 }
111 }
112
113 #[test]
116 fn detects_double_from_and_mime_headers() {
117 for payload in [
118 "From: a@b.c\nFrom: c@d.e",
119 "From: a@b.c\r\nFrom: c@d.e",
120 "victim@example.com\r\nFrom: attacker@evil.com\r\nFrom: attacker2@evil.com",
122 "MIME-Version: 1.0",
123 "victim@example.com\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed",
125 "Return-Path: <a@b.c>\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"----=_x\"\n\n--x--\n",
127 ] {
128 let r = MailHeaderDetector
129 .detect(payload)
130 .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
131 assert_eq!(r.severity, Severity::Medium, "payload {:?}", payload);
132 assert!(
133 !r.matched_pattern.is_empty(),
134 "matched_pattern empty for {:?}",
135 payload
136 );
137 assert!(
138 r.offset <= payload.len(),
139 "offset out of range for {:?}",
140 payload
141 );
142 }
143 }
144
145 #[test]
149 fn recipient_headers_are_weak_signals() {
150 for input in [
151 "victim@example.com\r\nCc: attacker@evil.com",
153 "victim@example.com\r\nBcc: attacker@evil.com",
154 "FYI, see below.\n\n---------- Forwarded message ---------\n\
156 From: alice@example.com\nDate: Mon, 1 Sep 2026 09:00\nSubject: Re: invoice 4821\n\
157 To: bob@example.com\nCc: carol@example.com\n\nThanks!\n",
158 "From: Alice <alice@example.com>\nTo: Bob <bob@example.com>\n\
160 Cc: Carol <carol@example.com>\nSubject: Re: invoice\n\nSee attached.\n",
161 ] {
162 let r = MailHeaderDetector
163 .detect(input)
164 .unwrap_or_else(|| panic!("expected weak-signal detection for {:?}", input));
165 assert_eq!(r.severity, Severity::Low, "input {:?}", input);
166 }
167 }
168
169 #[test]
176 fn pasted_mime_mail_is_medium_via_mime_version_header() {
177 let input = "From: Alice <alice@example.com>\nTo: Bob <bob@example.com>\n\
178 Cc: Carol <carol@example.com>\nBcc: Dave <dave@example.com>\n\
179 Subject: Re: invoice\nMIME-Version: 1.0\n\
180 Content-Type: text/plain; charset=utf-8\n\nSee attached.\n";
181 let r = MailHeaderDetector.detect(input).expect("detection");
182 assert_eq!(r.severity, Severity::Medium);
183 assert_eq!(r.matched_pattern, "MIME-Version:");
184 }
185
186 #[test]
191 fn doc_tables_and_prose_mentions_are_clean() {
192 for input in [
193 "| **mail_header** | `Bcc:`/`Cc:` 密送注入、`From:` 多重发件人、`MIME-Version:`/`Content-Type: multipart` MIME 头注入、`boundary=` 边界操纵 | Medium |",
195 "| `mail_header` | `Bcc:` / `Cc:` / `MIME-Version:` / `boundary=`、`Content-Type: ...multipart`、重复 `From:` |",
197 "For MIME-Version: see the docs.",
199 "The cc: field on the signup page is broken.",
200 "Set the From: address first.\nThen the From: line is filled automatically.",
202 ] {
203 crate::test_helpers::assert_clean(&MailHeaderDetector, input);
204 }
205 }
206
207 #[test]
208 fn ignores_benign_inputs() {
209 for input in [
210 "Hello, this is a normal text input.",
211 "Bcc victim@evil.com",
212 "From: a@b.c",
213 "Content-Type: text/plain",
214 "boundary abc",
215 "MIME-Version",
216 "multipart/form-data",
217 ] {
218 assert!(
219 MailHeaderDetector.detect(input).is_none(),
220 "false positive: {:?}",
221 input
222 );
223 }
224 }
225
226 #[test]
230 fn ignores_prose_mentions_and_benign_emails() {
231 for input in [
232 "Please cc: my manager on this reply.",
233 "boundary=0.5",
234 "The boundary=0.5 is the decision threshold, cc: the QA list too.",
235 "Customer note: please cc: billing when the ticket boundary=0.5 is exceeded. \
237 We also CC: nobody — see the FAQ (cc: means carbon copy).",
238 "POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Type: multipart/form-data; boundary=----x\r\nContent-Length: 1024\r\n\r\n",
241 "POST /api/files HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer abc\r\n\
243 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW\r\n\r\n",
244 "Content-Type: multipart/mixed; boundary=abc123",
248 "boundary=abc123",
249 "From: customer@example.com\nSubject: Re: order 1234\nDate: Mon, 1 Sep 2026 10:00:00 +0800\n\n\
251 Hi, please cc: my manager on this reply.\nThanks,\nDana\n",
252 ] {
253 assert!(
254 MailHeaderDetector.detect(input).is_none(),
255 "false positive: {:?}",
256 input
257 );
258 }
259 }
260
261 #[test]
262 fn edge_cases() {
263 assert!(MailHeaderDetector.detect("").is_none());
264 assert!(MailHeaderDetector.detect(" ").is_none());
265 assert!(
266 MailHeaderDetector
267 .detect("BCC: evil@example.com")
268 .is_none()
269 ); }
271}