Skip to main content

security_rust/data/
mail_header.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
8    vec![
9        Regex::new(r"(?i)Bcc\s*:").unwrap(),
10        Regex::new(r"(?i)Cc\s*:").unwrap(),
11        Regex::new(r"(?i)From\s*:.*\r?\n.*From\s*:").unwrap(),
12        Regex::new(r"(?i)MIME-Version\s*:").unwrap(),
13        Regex::new(r"(?i)Content-Type\s*:.*multipart").unwrap(),
14        Regex::new(r"(?i)boundary\s*=").unwrap(),
15    ]
16});
17
18pub struct MailHeaderDetector;
19
20impl Detector for MailHeaderDetector {
21    fn name(&self) -> &'static str {
22        "mail_header"
23    }
24
25    fn detect(&self, input: &str) -> Option<DetectionResult> {
26        regex_detect(
27            &PATTERNS,
28            self.name(),
29            AttackCategory::Data,
30            Severity::Medium,
31            "Mail header injection detected",
32            input,
33        )
34    }
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    #[test]
42    fn name_returns_attack_type() {
43        assert_eq!(MailHeaderDetector.name(), "mail_header");
44    }
45
46    #[test]
47    fn detects_injected_recipient_headers() {
48        for payload in [
49            "Bcc: victim@evil.com",
50            "Cc: victim@evil.com",
51            "bcc: lower@case.com",
52        ] {
53            let r = MailHeaderDetector
54                .detect(payload)
55                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
56            assert_eq!(r.attack_type, "mail_header");
57            assert_eq!(r.category, AttackCategory::Data);
58            assert_eq!(r.severity, Severity::Medium);
59            assert!(
60                !r.matched_pattern.is_empty(),
61                "matched_pattern empty for {:?}",
62                payload
63            );
64            assert!(
65                r.offset <= payload.len(),
66                "offset out of range for {:?}",
67                payload
68            );
69        }
70    }
71
72    #[test]
73    fn detects_double_from_and_mime_headers() {
74        for payload in [
75            "From: a@b.c\nFrom: c@d.e",
76            "From: a@b.c\r\nFrom: c@d.e",
77            "MIME-Version: 1.0",
78            "Content-Type: multipart/mixed; boundary=abc123",
79            "boundary=abc123",
80        ] {
81            let r = MailHeaderDetector
82                .detect(payload)
83                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
84            assert!(
85                !r.matched_pattern.is_empty(),
86                "matched_pattern empty for {:?}",
87                payload
88            );
89            assert!(
90                r.offset <= payload.len(),
91                "offset out of range for {:?}",
92                payload
93            );
94        }
95    }
96
97    #[test]
98    fn ignores_benign_inputs() {
99        for input in [
100            "Hello, this is a normal text input.",
101            "Bcc victim@evil.com",
102            "From: a@b.c",
103            "Content-Type: text/plain",
104            "boundary abc",
105            "MIME-Version",
106            "multipart/form-data",
107        ] {
108            assert!(
109                MailHeaderDetector.detect(input).is_none(),
110                "false positive: {:?}",
111                input
112            );
113        }
114    }
115
116    #[test]
117    fn edge_cases() {
118        assert!(MailHeaderDetector.detect("").is_none());
119        assert!(MailHeaderDetector.detect("   ").is_none());
120        assert!(
121            MailHeaderDetector
122                .detect("BCC: evil@example.com")
123                .is_none()
124        ); // fullwidth letters
125    }
126}