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
7/// 强信号:要求的是**结构异常**,不是「某个头名出现过」。
8/// 实测 39 条输入(tests/real_world_corpus.rs 的 16 条 + 转发邮件 / 邮件原文 / 邮件日志 /
9/// 带 `From:` 头的 HTTP 请求等探针 16 条 + 本仓库 README / docs / 源码 7 份):
10/// 两条强档一共命中 4 条——2 条攻击样例,外加 2 条已知残留(粘贴整封 MIME 来信原文、
11/// HTTP 请求带两个 `From:` 头),见 `pasted_mime_mail_is_medium_via_mime_version_header`。
12static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13    // 两条分支的 flags 完全一致(`(?m)(?i)`),提到最前面即可,无需逐条包裹。
14    Regex::new(
15        &[
16            // 双发件人:相邻两行各是一个 `From:` 头。两处都锚行首——`Set the From: address.` 这种
17            // 句子里的 `From:` 不算,认的是「相邻两个 From: 头」这个结构。正常邮件、转发邮件、
18            // 引用回复都只有一个 `From:`。
19            r"(?mi)^From\s*:.*\r?\n^From\s*:",
20            // `MIME-Version:` 只出现在真正的 MIME 报文里:RFC 2045 定义它,HTTP 的字段表
21            // (RFC 7231)里没有这个名字,所以行首出现即邮件结构信号。
22            // 必须锚行首:README.md / docs/OWASP-COVERAGE.md 的表格里写着 `MIME-Version:` 来介绍
23            // 本检测器,未锚版实测把本库自己的 README 判成 Medium(与 pet.svg 同一类误报)。
24            r"|^MIME-Version\s*:",
25            // 曾有 `(?i)Content-Type\s*:.*multipart`——`Content-Type: multipart/form-data` 是每个
26            // 文件上传 POST 的标准头,与邮件正文同形。检测器分不清 HTTP 请求和邮件,删。
27            // 连带代价:`boundary=` 也随之不再被覆盖(它只在 multipart 上下文里才有意义,
28            // 而那个上下文已经不作为信号),没有 `MIME-Version:` 的裸 multipart 片段不再命中。
29        ]
30        .concat(),
31    )
32    .unwrap()
33});
34
35/// 弱信号:行首的 `Cc:` / `Bcc:` 是**正常抄送头与注入头逐字节同形**的那一类。
36/// 转发邮件(`---------- Forwarded message ---------` 紧接着就是 `Cc: …`)、客服系统摄入的
37/// 来信、邮件原文粘贴,全都带行首 `Cc:`;正则分不出「粘贴进来的来信」和「注入的头」。
38/// 收紧不了(要认的就是这个字节序列),故报 Low:仍然命中、不静默漏报,但单条 5 分不单独
39/// 触发拒绝,是否升级交给调用方按聚合分决定。
40static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
41    // 同强档:两条分支 flags 一致,提到最前面。
42    // 抄送头必须是行首:`Please cc: my manager` 是散文,注入才是 `\r\nCc: ...`
43    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    /// 攻击方向:行首的抄送头仍然检出。severity 由 Medium 降为 Low —— 因为
85    /// 「行首 `Cc:`」与转发邮件 / 邮件原文里的正常抄送头逐字节同形,见
86    /// `recipient_headers_are_weak_signals` 里的良性对照。
87    #[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    /// 强档两方向之一:攻击形态仍是 Medium(`From:` 重复 / 行首 `MIME-Version:`)。
114    /// 抄送头(`Cc:` / `Bcc:`)已移到弱档,见 `recipient_headers_are_weak_signals`。
115    #[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            // 注入形态:两个 From: 头各占一行
121            "victim@example.com\r\nFrom: attacker@evil.com\r\nFrom: attacker2@evil.com",
122            "MIME-Version: 1.0",
123            // 注入形态:换行后的行首 MIME 头
124            "victim@example.com\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed",
125            // 真实 MIME 邮件:`Content-Type: multipart` 不再是信号后,靠 `MIME-Version:` 命中
126            "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    /// 弱档两方向:抄送头注入仍然检出(不静默漏报),但报 Low;
146    /// 与它逐字节同形的良性输入(转发邮件正文、邮件原文)同样只到 Low,不单独拒绝。
147    /// 降档理由见 `WEAK_PATTERNS` 的注释。
148    #[test]
149    fn recipient_headers_are_weak_signals() {
150        for input in [
151            // 攻击:注入的抄送头
152            "victim@example.com\r\nCc: attacker@evil.com",
153            "victim@example.com\r\nBcc: attacker@evil.com",
154            // 良性:转发的邮件正文——`Cc:` 就在行首,与上面那条注入无法区分
155            "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            // 良性:客服系统摄入的来信原文(非 MIME,没有 `MIME-Version:` 头)
159            "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    /// 已知残留(待定,不是疏漏):粘贴进来的**真实 MIME 来信**在行首带 `MIME-Version:`,
170    /// 仍会命中强档 Medium(15 分)。它单独不越过拒绝线(15 < 40),但会参与叠加。
171    /// 保留它的理由:这是本检测器唯一还能认出「MIME 报文结构」的信号,删掉后
172    /// mail_header 对 MIME 注入完全无感(`Content-Type: multipart` 已于上一轮删除,
173    /// 见 `STRONG_PATTERNS` 注释)。实测 `MIME-Version:` 不出现在 HTTP 请求头里,
174    /// 所以这条 FP 只在「把整封信原文喂给扫描器」时出现。
175    #[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    /// 强档两方向之二:收紧后必须**完全干净**的良性输入——本库自己的文档表格、
187    /// 散文里提到的头名、句中(非行首)的 `From:` 对。
188    /// 前两条实测:未锚行首的 `MIME-Version\s*:` 会把 README.md / docs/OWASP-COVERAGE.md
189    /// 判成 Medium(score=15)——和 pet.svg 一样,是「检测器扫不动自己的文档」。
190    #[test]
191    fn doc_tables_and_prose_mentions_are_clean() {
192        for input in [
193            // README.md:173 原文(本检测器的介绍行)
194            "| **mail_header** | `Bcc:`/`Cc:` 密送注入、`From:` 多重发件人、`MIME-Version:`/`Content-Type: multipart` MIME 头注入、`boundary=` 边界操纵 | Medium |",
195            // docs/OWASP-COVERAGE.md:72 原文
196            "| `mail_header` | `Bcc:` / `Cc:` / `MIME-Version:` / `boundary=`、`Content-Type: ...multipart`、重复 `From:` |",
197            // 散文里提到头名(`MIME-Version:` 不在行首)
198            "For MIME-Version: see the docs.",
199            "The cc: field on the signup page is broken.",
200            // 句中出现的两个 `From:`(不是相邻两行的头)
201            "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    /// 真实语料:`cc:` / `boundary=` 出现在散文里不是邮件头。裸模式实测把这类工单文字
227    /// 报成 Medium(score=15,命中的是 `cc:`);Medium 每条 15 分、3 条即到 40 分的
228    /// 拒绝线(`risk.level >= High`),散文里一个 `cc:` 就是白送的 15 分。
229    #[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            // 工单 / 支持记录:同一类型文字里的 `cc:` 与 `boundary=`
236            "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            // 最普通的文件上传请求:`Content-Type: multipart/form-data` 曾经让每个上传
239            // POST 白得 15 分,是越线的最佳配料。这是必须保持干净的负控。
240            "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            // 同一形态的变体(无 Content-Length / 带 Authorization)
242            "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            // 覆盖率取舍(已批准):不带 `MIME-Version:` 的裸 multipart 邮件片段不再命中——
245            // 它与上传请求在字节层面同形,正则分不出,只能选一边。真实 MIME 邮件带
246            // `MIME-Version:`,仍在上面的命中列表里。
247            "Content-Type: multipart/mixed; boundary=abc123",
248            "boundary=abc123",
249            // 真实来信(非 MIME):头 + 正文都没有本检测器该报的东西
250            "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        ); // fullwidth letters
270    }
271}