use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?mi)^From\s*:.*\r?\n^From\s*:",
r"|^MIME-Version\s*:",
]
.concat(),
)
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?mi)^Bcc\s*:|^Cc\s*:").unwrap()
});
pub struct MailHeaderDetector;
impl Detector for MailHeaderDetector {
fn name(&self) -> &'static str {
"mail_header"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Data,
Severity::Medium,
"Mail header injection detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Data,
Severity::Low,
"Recipient header present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_returns_attack_type() {
assert_eq!(MailHeaderDetector.name(), "mail_header");
}
#[test]
fn detects_injected_recipient_headers() {
for payload in [
"Bcc: victim@evil.com",
"Cc: victim@evil.com",
"bcc: lower@case.com",
] {
let r = MailHeaderDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert_eq!(r.attack_type, "mail_header");
assert_eq!(r.category, AttackCategory::Data);
assert_eq!(r.severity, Severity::Low);
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn detects_double_from_and_mime_headers() {
for payload in [
"From: a@b.c\nFrom: c@d.e",
"From: a@b.c\r\nFrom: c@d.e",
"victim@example.com\r\nFrom: attacker@evil.com\r\nFrom: attacker2@evil.com",
"MIME-Version: 1.0",
"victim@example.com\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed",
"Return-Path: <a@b.c>\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"----=_x\"\n\n--x--\n",
] {
let r = MailHeaderDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert_eq!(r.severity, Severity::Medium, "payload {:?}", payload);
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn recipient_headers_are_weak_signals() {
for input in [
"victim@example.com\r\nCc: attacker@evil.com",
"victim@example.com\r\nBcc: attacker@evil.com",
"FYI, see below.\n\n---------- Forwarded message ---------\n\
From: alice@example.com\nDate: Mon, 1 Sep 2026 09:00\nSubject: Re: invoice 4821\n\
To: bob@example.com\nCc: carol@example.com\n\nThanks!\n",
"From: Alice <alice@example.com>\nTo: Bob <bob@example.com>\n\
Cc: Carol <carol@example.com>\nSubject: Re: invoice\n\nSee attached.\n",
] {
let r = MailHeaderDetector
.detect(input)
.unwrap_or_else(|| panic!("expected weak-signal detection for {:?}", input));
assert_eq!(r.severity, Severity::Low, "input {:?}", input);
}
}
#[test]
fn pasted_mime_mail_is_medium_via_mime_version_header() {
let input = "From: Alice <alice@example.com>\nTo: Bob <bob@example.com>\n\
Cc: Carol <carol@example.com>\nBcc: Dave <dave@example.com>\n\
Subject: Re: invoice\nMIME-Version: 1.0\n\
Content-Type: text/plain; charset=utf-8\n\nSee attached.\n";
let r = MailHeaderDetector.detect(input).expect("detection");
assert_eq!(r.severity, Severity::Medium);
assert_eq!(r.matched_pattern, "MIME-Version:");
}
#[test]
fn doc_tables_and_prose_mentions_are_clean() {
for input in [
"| **mail_header** | `Bcc:`/`Cc:` 密送注入、`From:` 多重发件人、`MIME-Version:`/`Content-Type: multipart` MIME 头注入、`boundary=` 边界操纵 | Medium |",
"| `mail_header` | `Bcc:` / `Cc:` / `MIME-Version:` / `boundary=`、`Content-Type: ...multipart`、重复 `From:` |",
"For MIME-Version: see the docs.",
"The cc: field on the signup page is broken.",
"Set the From: address first.\nThen the From: line is filled automatically.",
] {
crate::test_helpers::assert_clean(&MailHeaderDetector, input);
}
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input.",
"Bcc victim@evil.com",
"From: a@b.c",
"Content-Type: text/plain",
"boundary abc",
"MIME-Version",
"multipart/form-data",
] {
assert!(
MailHeaderDetector.detect(input).is_none(),
"false positive: {:?}",
input
);
}
}
#[test]
fn ignores_prose_mentions_and_benign_emails() {
for input in [
"Please cc: my manager on this reply.",
"boundary=0.5",
"The boundary=0.5 is the decision threshold, cc: the QA list too.",
"Customer note: please cc: billing when the ticket boundary=0.5 is exceeded. \
We also CC: nobody — see the FAQ (cc: means carbon copy).",
"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",
"POST /api/files HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer abc\r\n\
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW\r\n\r\n",
"Content-Type: multipart/mixed; boundary=abc123",
"boundary=abc123",
"From: customer@example.com\nSubject: Re: order 1234\nDate: Mon, 1 Sep 2026 10:00:00 +0800\n\n\
Hi, please cc: my manager on this reply.\nThanks,\nDana\n",
] {
assert!(
MailHeaderDetector.detect(input).is_none(),
"false positive: {:?}",
input
);
}
}
#[test]
fn edge_cases() {
assert!(MailHeaderDetector.detect("").is_none());
assert!(MailHeaderDetector.detect(" ").is_none());
assert!(
MailHeaderDetector
.detect("BCC: evil@example.com")
.is_none()
); }
}