Skip to main content

waf_detection/
mail.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules (Fase 10a) ────────────────────────────────────────────────────────────
11//
12// Mail (SMTP/IMAP) command injection: a CR/LF followed by a mail-protocol verb smuggled
13// into a user value, to inject extra SMTP/IMAP commands. Scope (§8): the mail-COMMAND
14// vocabulary after CR/LF — distinct from `header_injection`, which owns HTTP response-
15// splitting (Set-Cookie / status). The two OVERLAP on the CR/LF mechanism (a mail payload
16// also trips header_injection's CR/LF-in-query rule); that is declared defense-in-depth,
17// attribution stays `case.module = Mail`.
18//
19// FP discipline: the rule REQUIRES the CR/LF — a value that merely contains "QUIT" or
20// "MAIL FROM" without a newline is not flagged (benign guards). Only unequivocal verbs are
21// listed (no bare "DATA"/"LOGIN" which are common words). Base64Flat duplicates are
22// 10c-scope (`ExpectedMiss{until_phase:"10c"}`).
23
24pub static MAIL_RULES: &[Rule] = &[
25    Rule {
26        id: "mail-command-injection",
27        // CR/LF, optional IMAP tag (`V100 `), then an SMTP/IMAP verb.
28        pattern: r"(?i)[\r\n]\s*(?:\w+\s+)?(?:CAPABILITY|FETCH|QUIT|RCPT\s+TO|MAIL\s+FROM|STARTTLS|EHLO|HELO)\b",
29        severity: Severity::Critical,
30        paranoia: 1,
31    },
32];
33
34// ── module ──────────────────────────────────────────────────────────────────────
35
36#[derive(Default)]
37pub struct MailModule {
38    rule_set: Option<RegexSet>,
39    active_rules: Vec<&'static Rule>,
40}
41
42impl MailModule {
43    pub fn new() -> Self {
44        Self::default()
45    }
46}
47
48impl WafModule for MailModule {
49    fn id(&self) -> &str {
50        "mail"
51    }
52
53    fn phase(&self) -> Phase {
54        Phase::Body
55    }
56
57    fn init(&mut self, cfg: &Config) {
58        let pl = cfg.waf.paranoia_level;
59        self.active_rules = MAIL_RULES.iter().filter(|r| r.paranoia <= pl).collect();
60        self.rule_set = Some(
61            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
62                .expect("Mail rule compilation failed — check patterns at startup"),
63        );
64    }
65
66    fn inspect(&self, ctx: &RequestContext) -> Decision {
67        let Some(rule_set) = &self.rule_set else {
68            return Decision::Allow;
69        };
70
71        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
72        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
73        let body_vals = body_str_values(&ctx.normalized.body);
74        let body = body_vals.iter().map(String::as_str);
75        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
76
77        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
78        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
79        // path-placed payload bypasses. The prefilter already scans the path (sound).
80        let path = std::iter::once(ctx.normalized.path.as_str());
81        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
82        let headers = inspectable_header_values(ctx);
83        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
84        if matched.is_empty() {
85            return Decision::Allow;
86        }
87
88        let items: Vec<ScoreItem> = matched
89            .iter()
90            .map(|&idx| {
91                let rule = self.active_rules[idx];
92                warn!(
93                    request_id = %ctx.request_id,
94                    rule_id = %rule.id,
95                    severity = ?rule.severity,
96                    "mail detection"
97                );
98                ScoreItem {
99                    rule_id: rule.id.to_string(),
100                    severity: rule.severity,
101                }
102            })
103            .collect();
104
105        Decision::Scores(items)
106    }
107}