Skip to main content

waf_detection/
header_injection.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::Regex;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8// ── design note ───────────────────────────────────────────────────────────────
9//
10// hyper / the `http` crate reject CR/LF/NUL inside incoming header VALUES at
11// parse time, so classic CRLF injection in the header values themselves never
12// reaches the WAF. The live attack surface in the request is:
13//   1. CRLF smuggled (percent-encoded) into query/body params — `%0d%0a…` —
14//      which Fase 2 decodes; the backend may reflect it into a response header.
15//   2. Host header injection to an absolute URI (`Host: http://evil`), which
16//      hyper DOES allow (no control chars).
17//
18//   3. CRLF smuggled (percent-encoded) into the URL PATH — `/%0d%0aSet-Cookie:…` —
19//      which Fase 2 decodes into `normalized.path`; same response-splitting risk.
20//
21// This is the first FIELD-AWARE module: rules carry a `scope` so that bare CR/LF
22// is flagged where it is anomalous (path/query/cookie/header) but tolerated in the
23// body (legit textarea line breaks) except at high paranoia. `Phase::Headers`
24// only sets pipeline ORDERING — not which fields are inspected (all normalized
25// fields are available regardless of phase).
26
27#[derive(Clone, Copy, PartialEq)]
28enum Scope {
29    /// path + query params + cookies + header values + body.
30    All,
31    /// path + query params + cookies + header values (NOT body — textarea-safe).
32    NonBody,
33    /// values of `host` / `x-forwarded-host` headers only.
34    HostHeaders,
35    /// body values only.
36    Body,
37}
38
39struct HdrRule {
40    id: &'static str,
41    pattern: &'static str,
42    severity: Severity,
43    paranoia: u8,
44    scope: Scope,
45}
46
47static HEADER_INJECTION_RULES: &[HdrRule] = &[
48    HdrRule {
49        id: "hdr-crlf-header-injection",
50        // CR/LF followed by an injectable response-header name + colon.
51        pattern: r"(?i)[\r\n]\s*(?:set-cookie|location|content-type|content-length|content-disposition|refresh|link|x-forwarded-for|x-forwarded-host|host)\s*:",
52        severity: Severity::Critical,
53        paranoia: 1,
54        scope: Scope::All,
55    },
56    HdrRule {
57        id: "hdr-crlf-control-char",
58        // Bare CR/LF where a newline is anomalous (not the body).
59        pattern: r"[\r\n]",
60        severity: Severity::Warning,
61        paranoia: 2,
62        scope: Scope::NonBody,
63    },
64    HdrRule {
65        id: "hdr-host-injection",
66        // A Host header must be host[:port] — it never legitimately contains
67        // `/`, `@` or a scheme. Any of those means absolute-URI / userinfo Host
68        // injection (cache poisoning / routing). IPv6 literals like
69        // `[2001:db8::1]:8080` contain none of these, so no false positive.
70        pattern: r"(?i)(?:[/@]|https?:)",
71        severity: Severity::Warning,
72        paranoia: 2,
73        scope: Scope::HostHeaders,
74    },
75    HdrRule {
76        id: "hdr-crlf-in-body",
77        // Bare CR/LF in the body: legit for textareas, so Notice/PL3 only.
78        pattern: r"[\r\n]",
79        severity: Severity::Notice,
80        paranoia: 3,
81        scope: Scope::Body,
82    },
83];
84
85/// `(id, pattern, paranoia, is_host_only)` for every header-injection rule. The
86/// single source the Pilastro 3 content prefilter reads for this module (the
87/// `HdrRule`/`Scope` types stay private), so the prefilter cannot drift from these
88/// rules. `is_host_only` is derived from the real `Scope::HostHeaders` — it is the
89/// one rule whose pattern (`[/@]`) is broad and MUST be scanned only against host
90/// header values, never the path/query (else it matches every `/`).
91pub fn rule_meta() -> Vec<(&'static str, &'static str, u8, bool)> {
92    HEADER_INJECTION_RULES
93        .iter()
94        .map(|r| (r.id, r.pattern, r.paranoia, matches!(r.scope, Scope::HostHeaders)))
95        .collect()
96}
97
98// ── module ────────────────────────────────────────────────────────────────────
99
100#[derive(Default)]
101pub struct HeaderInjectionModule {
102    /// Active (paranoia-filtered) rules with their compiled regex.
103    rules: Vec<(&'static HdrRule, Regex)>,
104}
105
106impl HeaderInjectionModule {
107    pub fn new() -> Self {
108        Self::default()
109    }
110}
111
112fn host_header_values(ctx: &RequestContext) -> Vec<&str> {
113    ctx.normalized
114        .headers
115        .iter()
116        .filter(|(name, _)| name == "host" || name == "x-forwarded-host")
117        .map(|(_, v)| v.as_str())
118        .collect()
119}
120
121impl WafModule for HeaderInjectionModule {
122    fn id(&self) -> &str {
123        "header_injection"
124    }
125
126    fn phase(&self) -> Phase {
127        // Ordering hint only; inspection spans query/body/cookies/headers.
128        Phase::Headers
129    }
130
131    fn init(&mut self, cfg: &Config) {
132        let pl = cfg.waf.paranoia_level;
133        self.rules = HEADER_INJECTION_RULES
134            .iter()
135            .filter(|r| r.paranoia <= pl)
136            .map(|r| {
137                let re = Regex::new(r.pattern)
138                    .expect("header-injection rule compilation failed — check patterns at startup");
139                (r, re)
140            })
141            .collect();
142    }
143
144    fn inspect(&self, ctx: &RequestContext) -> Decision {
145        if self.rules.is_empty() {
146            return Decision::Allow;
147        }
148
149        // Build the per-scope value sets once. The path is included in All/NonBody:
150        // CRLF smuggled into the URL PATH (`/%0d%0aSet-Cookie:…`, gotestwaf crlf)
151        // decodes to CR/LF in `normalized.path` (Fase 2) and would otherwise bypass a
152        // query/body-only scan. A legitimate path never carries CR/LF.
153        let path: &str = ctx.normalized.path.as_str();
154        let query: Vec<&str> = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str()).collect();
155        let cookies: Vec<&str> = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str()).collect();
156        let headers: Vec<&str> = ctx.normalized.headers.iter().map(|(_, v)| v.as_str()).collect();
157        let body_owned = crate::body_str_values(&ctx.normalized.body);
158        let body: Vec<&str> = body_owned.iter().map(String::as_str).collect();
159        let hosts = host_header_values(ctx);
160
161        let mut items: Vec<ScoreItem> = Vec::new();
162
163        for (rule, re) in &self.rules {
164            let matched = match rule.scope {
165                Scope::All => {
166                    re.is_match(path)
167                        || query.iter().chain(&cookies).chain(&headers).chain(&body).any(|v| re.is_match(v))
168                }
169                Scope::NonBody => {
170                    re.is_match(path)
171                        || query.iter().chain(&cookies).chain(&headers).any(|v| re.is_match(v))
172                }
173                Scope::HostHeaders => hosts.iter().any(|v| re.is_match(v)),
174                Scope::Body => body.iter().any(|v| re.is_match(v)),
175            };
176
177            if matched {
178                warn!(
179                    request_id = %ctx.request_id,
180                    rule_id = %rule.id,
181                    severity = ?rule.severity,
182                    "header-injection detection"
183                );
184                items.push(ScoreItem {
185                    rule_id: rule.id.to_string(),
186                    severity: rule.severity,
187                });
188            }
189        }
190
191        if items.is_empty() {
192            Decision::Allow
193        } else {
194            Decision::Scores(items)
195        }
196    }
197}