waf_normalizer/lib.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod body;
5pub mod graphql;
6pub mod grpc;
7pub mod url;
8
9use waf_core::{LimitsConfig, RequestContext};
10
11use crate::body::parse_body;
12use crate::url::{canonicalize_value, normalize_path, parse_cookies_limited, parse_query};
13
14// ── NormalizationError ────────────────────────────────────────────────────────
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum NormalizationError {
18 BodyTooLarge { limit: usize, actual: usize },
19 TooManyHeaders { limit: usize },
20 HeaderTooLarge { limit: usize, actual: usize },
21 TooManyParams { limit: usize },
22 TooManyCookies { limit: usize },
23 JsonDepthExceeded { limit: usize },
24 JsonParseError(String),
25 MultipartError(String),
26}
27
28impl std::fmt::Display for NormalizationError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::BodyTooLarge { limit, actual } =>
32 write!(f, "body too large: {actual} bytes (limit {limit})"),
33 Self::TooManyHeaders { limit } =>
34 write!(f, "too many headers (limit {limit})"),
35 Self::HeaderTooLarge { limit, actual } =>
36 write!(f, "header value too large: {actual} bytes (limit {limit})"),
37 Self::TooManyParams { limit } =>
38 write!(f, "too many parameters (limit {limit})"),
39 Self::TooManyCookies { limit } =>
40 write!(f, "too many cookies (limit {limit})"),
41 Self::JsonDepthExceeded { limit } =>
42 write!(f, "JSON nesting exceeds depth limit {limit}"),
43 Self::JsonParseError(msg) =>
44 write!(f, "JSON parse error: {msg}"),
45 Self::MultipartError(msg) =>
46 write!(f, "multipart error: {msg}"),
47 }
48 }
49}
50
51// ── Normalizer ────────────────────────────────────────────────────────────────
52
53pub struct Normalizer {
54 limits: LimitsConfig,
55}
56
57impl Normalizer {
58 pub fn new(limits: &LimitsConfig) -> Self {
59 Self { limits: limits.clone() }
60 }
61
62 /// Validate limits and populate `ctx.normalized` from the raw request fields.
63 ///
64 /// Call this before the pipeline runs. Returns an error (→ 400) if any
65 /// defensive limit is exceeded; the error is not recoverable.
66 pub fn normalize(&self, ctx: &mut RequestContext) -> Result<(), NormalizationError> {
67 let limits = &self.limits;
68
69 // ── 1. Body size ──────────────────────────────────────────────────────
70 let body_len = ctx.body.len();
71 if body_len > limits.max_body_size {
72 return Err(NormalizationError::BodyTooLarge {
73 limit: limits.max_body_size,
74 actual: body_len,
75 });
76 }
77
78 // ── 2. Header count + per-header value size ───────────────────────────
79 if ctx.headers.len() > limits.max_headers {
80 return Err(NormalizationError::TooManyHeaders { limit: limits.max_headers });
81 }
82 for (_name, value) in &ctx.headers {
83 if value.len() > limits.max_header_size {
84 return Err(NormalizationError::HeaderTooLarge {
85 limit: limits.max_header_size,
86 actual: value.len(),
87 });
88 }
89 }
90
91 // ── 3. Normalize header names (lowercase) and trim values ─────────────
92 let norm_headers: Vec<(String, String)> = ctx
93 .headers
94 .iter()
95 .map(|(k, v)| (k.to_lowercase(), v.trim().to_string()))
96 .collect();
97
98 // ── 4. Parse + canonicalize cookies (from normalized Cookie headers) ──
99 // Limits (max_cookies count, plus max_header_size on the raw header value
100 // in step 2) are enforced on the RAW text inside parse_cookies_limited,
101 // BEFORE any decoding — so an encoded cookie that expands cannot bypass
102 // them. Decoding then uses the SAME pass as query/body (canonicalize_value),
103 // except `+` stays literal (RFC 6265 cookies are not form-encoded).
104 // `derived_decoded`: base64 (10c) variants of inspected values, collected as
105 // we go. Decode-then-match-then-discard. Cookies are EXCLUDED from the base64
106 // channel (D3 — session cookies are base64-benign-heavy); they still get the
107 // canonical (overlong) decode.
108 let mut derived: Vec<String> = Vec::new();
109
110 let mut cookies = Vec::new();
111 let mut cookie_double_enc = false;
112 for (name, value) in &norm_headers {
113 if name == "cookie" {
114 for (k, v) in parse_cookies_limited(value, limits.max_cookies)? {
115 let (dk, de_k) = canonicalize_value(&k, false);
116 let (dv, de_v) = canonicalize_value(&v, false);
117 cookie_double_enc |= de_k || de_v;
118 cookies.push((dk, dv)); // NB: no base64_derived — cookie surface excluded
119 }
120 }
121 }
122
123 // ── 5. Normalize path ─────────────────────────────────────────────────
124 let (norm_path, path_double_enc) = normalize_path(&ctx.raw_path);
125
126 // base64-derived from the URL PATH segments (10c REOPEN, pcap-confirmed:
127 // gotestwaf places Base64Flat blobs AS the path, e.g. `/PGJvZHkg…`). The path
128 // rules already scan `norm_path`, but the DECODE channel must reach it too.
129 // Use the RAW path (case-PRESERVED — base64 is case-sensitive and normalize_path
130 // lowercases), split on the literal `/` separators, percent-decode each segment
131 // (path mode → `+` is literal), then derive. Normal path segments fail candidacy
132 // (too short / non-alphabet) → no cost, no FP.
133 for raw_seg in ctx.raw_path.split('/').filter(|s| !s.is_empty()) {
134 let (seg, _) = url::percent_decode(raw_seg, false);
135 derived.extend(url::derive_variants(&seg));
136 }
137
138 // ── 6. Parse query params (+ base64-derived) ──────────────────────────
139 let (query_params, query_double_enc) = match &ctx.query {
140 Some(q) => {
141 let (p, de, d) = parse_query(q, limits)?;
142 derived.extend(d);
143 (p, de)
144 }
145 None => (Vec::new(), false),
146 };
147
148 // ── 7. Parse body (+ base64-derived from body values) ─────────────────
149 let content_type = norm_headers
150 .iter()
151 .find(|(k, _)| k == "content-type")
152 .map(|(_, v)| v.as_str());
153
154 let parsed_body = parse_body(content_type, &ctx.body, limits)?;
155 // Body-derived inspection surface (10c). JSON leaves get the FULL decode
156 // (percent + overlong + base64) into the derived channel because
157 // `body_str_values` inspects JSON values RAW (serde unescapes `\u` but does NOT
158 // percent/overlong-decode; form & multipart already canonicalize their values).
159 // EVERY flattened leaf is processed → nested objects/arrays are covered too.
160 // Other body types only contribute base64-derived variants (already canonical).
161 match &parsed_body {
162 waf_core::ParsedBody::JsonFlattened(pairs) => {
163 for (_, v) in pairs {
164 derived.extend(url::json_leaf_derived(v));
165 }
166 }
167 // A `Raw` body (e.g. `application/graphql`, or any content-type the parser does
168 // not structure) is inspected RAW by `body_str_values` — it never goes through
169 // `canonicalize_value`, unlike form/multipart (canonicalized at parse) and JSON
170 // leaves (`json_leaf_derived`). So a percent/overlong-encoded payload in a raw
171 // body bypasses (Phase-11 Step-0 probe: `application/graphql` `%3Cscript%3E`
172 // reached score 0). Push the canonical form into the derived channel — same
173 // decode-then-match-then-discard pattern as the JSON leaf — plus the full
174 // transform set. The canonical itself is pushed only when it DIFFERS from the raw
175 // (when equal, `body_str_values` already inspects it; the guard also skips the
176 // clone for un-encoded / binary bodies).
177 waf_core::ParsedBody::Raw(b) => {
178 // gRPC (`application/grpc*`): the framed protobuf body is BINARY, so the
179 // canonical path below (`from_utf8`) extracts nothing. De-frame it and feed
180 // the length-delimited protobuf FIELDS (leaf strings) to the derived channel,
181 // so the content modules inspect a SQLi/XSS smuggled inside a field. This is
182 // the §6 CONTENT surface (always-on, like JSON-leaf decode) — the structural
183 // size/field/depth caps are the separate `grpc` module's job (accounting kept
184 // apart). A compressed/malformed body yields no leaves (the module's policy
185 // decides on those). Caps are generous defaults purely to bound the work.
186 let is_grpc = content_type
187 .map(|ct| ct.trim_start().starts_with("application/grpc"))
188 .unwrap_or(false);
189 if is_grpc {
190 let ex = crate::grpc::grpc_extract(b, crate::grpc::GrpcLimits::default());
191 for leaf in ex.leaves {
192 let canonical = url::canonicalize_value(&leaf, false).0;
193 derived.extend(url::derive_variants(&canonical));
194 // ALWAYS push the canonical leaf: unlike a UTF-8 raw body (which
195 // `body_str_values` inspects directly), the BINARY gRPC body is
196 // invisible to `body_str_values`, so the leaf only reaches the
197 // modules through this derived channel.
198 derived.push(canonical);
199 }
200 } else if let Ok(raw) = std::str::from_utf8(b) {
201 let canonical = url::canonicalize_value(raw, false).0;
202 derived.extend(url::derive_variants(&canonical));
203 if canonical != raw {
204 derived.push(canonical);
205 }
206 }
207 }
208 other => {
209 for s in body_canonical_strings(other) {
210 derived.extend(url::derive_variants(&s));
211 }
212 }
213 }
214
215 // base64-derived from header VALUES, minus the structural per-name exclusion
216 // (Authorization/Cookie/ETag/conditional-GET/`*-token`). Overlong is NOT
217 // excluded here — but header values are kept raw for header_injection; only the
218 // base64 channel reads them (the candidate is canonicalized inside base64_derived).
219 for (name, value) in &norm_headers {
220 if !header_base64_excluded(name) {
221 derived.extend(url::derive_variants(value));
222 }
223 }
224
225 // ── 8. Write results ──────────────────────────────────────────────────
226 ctx.normalized.path = norm_path;
227 ctx.normalized.query = ctx.query.clone();
228 ctx.normalized.query_params = query_params;
229 ctx.normalized.cookies = cookies;
230 ctx.normalized.headers = norm_headers;
231 ctx.normalized.body = parsed_body;
232 ctx.normalized.double_encoding_detected =
233 path_double_enc || query_double_enc || cookie_double_enc;
234 ctx.normalized.derived_decoded = derived;
235
236 Ok(())
237 }
238}
239
240/// Header names STRUCTURALLY excluded from the base64-derive channel (D3): their
241/// values are base64-benign-heavy and high-volume (Basic/Bearer auth, cookies, ETag
242/// / conditional-GET validators) so a per-name exclusion beats the statistical
243/// decode-then-match bet there. Case-insensitive (names are pre-lowercased). The
244/// OVERLONG channel is NOT subject to this — it stays pipeline-wide.
245fn header_base64_excluded(name: &str) -> bool {
246 const EXCLUDED: &[&str] = &[
247 "authorization",
248 "proxy-authorization",
249 "cookie",
250 "set-cookie",
251 "etag",
252 "if-none-match",
253 "if-match",
254 ];
255 EXCLUDED.contains(&name) || name.ends_with("-token")
256}
257
258/// Closed ALLOWLIST of header names whose VALUE the content-inspection modules scan
259/// (P1-B). Unlike query/body, request headers are mostly NOT attacker payload carriers,
260/// so we invert the policy: inspect ONLY the user-controlled forwarding headers
261/// (`Referer`, `X-Forwarded-{For,Host,Proto}`) and custom `x-*` headers (gotestwaf
262/// injects into `X-<random>`), and EXCLUDE everything else — even an `x-*` that is really
263/// infra/secret (`*-token`, `proxy-*`) or negotiation/validators (`accept*`, `content-*`,
264/// `etag`, `if-*`) or hop-by-hop. Names are pre-lowercased. The deny-list takes
265/// precedence over the `x-*` allowance. (Distinct from [`header_base64_excluded`], which
266/// is a deny-list for the separate base64-derive channel.)
267pub fn header_content_inspectable(name: &str) -> bool {
268 // Deny-list FIRST (overrides the x-* allowance).
269 const DENY_EXACT: &[&str] = &[
270 "authorization", "proxy-authorization", "cookie", "set-cookie",
271 "user-agent", "host", "etag", "if-none-match", "if-match",
272 // hop-by-hop (RFC 7230 §6.1) + framing controls
273 "connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade",
274 ];
275 if DENY_EXACT.contains(&name)
276 || name.ends_with("-token")
277 || name.starts_with("accept")
278 || name.starts_with("content-")
279 || name.starts_with("proxy-")
280 {
281 return false;
282 }
283 // Allow-list: forwarding headers + Referer + any custom x-*.
284 matches!(name, "referer" | "x-forwarded-for" | "x-forwarded-host" | "x-forwarded-proto")
285 || name.starts_with("x-")
286}
287
288/// Canonical inspectable strings of a parsed body (form values, JSON leaf values,
289/// multipart name/filename/UTF-8 value) — the surface the base64-derive channel scans.
290/// Mirrors detection's `body_str_values` but lives here so the normalizer can build
291/// `derived_decoded` once (the prefilter + every module then read it).
292fn body_canonical_strings(body: &waf_core::ParsedBody) -> Vec<String> {
293 use waf_core::ParsedBody;
294 match body {
295 ParsedBody::FormUrlEncoded(p) => p.iter().map(|(_, v)| v.clone()).collect(),
296 ParsedBody::JsonFlattened(p) => {
297 p.iter().map(|(_, v)| url::canonicalize_value(v, false).0).collect()
298 }
299 ParsedBody::Multipart(fields) => {
300 let mut out = Vec::with_capacity(fields.len() * 2);
301 for f in fields {
302 out.push(url::canonicalize_multipart_field(&f.name));
303 if let Some(fname) = &f.filename {
304 out.push(url::canonicalize_multipart_field(fname));
305 }
306 if let Ok(s) = std::str::from_utf8(&f.data) {
307 out.push(url::canonicalize_multipart_field(s));
308 }
309 }
310 out
311 }
312 ParsedBody::Raw(b) => {
313 std::str::from_utf8(b).ok().map(|s| url::canonicalize_value(s, false).0).into_iter().collect()
314 }
315 ParsedBody::None => Vec::new(),
316 }
317}