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}