Skip to main content

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}