Skip to main content

security_rust/data/
mail_header.rs

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