Skip to main content

waf_normalizer/
url.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use unicode_normalization::UnicodeNormalization;
5
6use waf_core::LimitsConfig;
7
8use crate::NormalizationError;
9
10// ── helpers ───────────────────────────────────────────────────────────────────
11
12fn is_hex(b: u8) -> bool {
13    b.is_ascii_hexdigit()
14}
15
16fn hex_val(b: u8) -> u8 {
17    match b {
18        b'0'..=b'9' => b - b'0',
19        b'a'..=b'f' => b - b'a' + 10,
20        b'A'..=b'F' => b - b'A' + 10,
21        _ => 0,
22    }
23}
24
25fn still_percent_encoded(s: &str) -> bool {
26    let b = s.as_bytes();
27    let mut i = 0;
28    while i + 2 < b.len() {
29        if b[i] == b'%' && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
30            return true;
31        }
32        i += 1;
33    }
34    false
35}
36
37// ── public API ────────────────────────────────────────────────────────────────
38
39/// Percent-decode a string (single pass).
40///
41/// Returns `(decoded, double_encoding_detected)`.
42/// `double_encoding_detected` is true when the decoded output still contains
43/// `%XX` sequences, meaning the input was double-encoded.
44/// If `plus_as_space` is true, `'+'` is decoded as `' '` (query-string mode).
45pub fn percent_decode(input: &str, plus_as_space: bool) -> (String, bool) {
46    let b = input.as_bytes();
47    let mut out: Vec<u8> = Vec::with_capacity(b.len());
48    let mut i = 0;
49
50    while i < b.len() {
51        if b[i] == b'+' && plus_as_space {
52            out.push(b' ');
53            i += 1;
54        } else if b[i] == b'%' && i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
55            out.push((hex_val(b[i + 1]) << 4) | hex_val(b[i + 2]));
56            i += 3;
57        } else {
58            out.push(b[i]);
59            i += 1;
60        }
61    }
62
63    let decoded = String::from_utf8_lossy(&out).into_owned();
64    let double_enc = still_percent_encoded(&decoded);
65    (decoded, double_enc)
66}
67
68/// Canonicalize a single field value: recursive percent-decode + **overlong-UTF8
69/// collapse** to a fixed point (Fase 10c — overlong is now folded into the canonical
70/// surface PIPELINE-WIDE, not scoped to multipart), then NFKC normalization. The
71/// single source of truth for value canonicalization shared by query, body, cookies
72/// and multipart. Overlong collapse is a *canonical* transform (the same value, a
73/// legal re-encoding decoded) — distinct from the *derived* base64 channel below.
74///
75/// `plus_as_space` follows the form-encoding convention: `true` for query/body,
76/// `false` for cookies (RFC 6265 treats `+` as a literal, not a space).
77///
78/// Returns `(canonical, double_encoding_detected)`.
79pub fn canonicalize_value(raw: &str, plus_as_space: bool) -> (String, bool) {
80    let mut budget = PIPELINE_CAP;
81    let (bytes, passes) = percent_overlong_fixpoint(raw.as_bytes(), plus_as_space, &mut budget);
82    let canonical: String = String::from_utf8_lossy(&bytes).nfkc().collect();
83    (canonical, passes >= 2)
84}
85
86// ── §6 decode pipeline (Fase 10c) ─────────────────────────────────────────────
87//
88// TWO stages with ONE shared budget (`PIPELINE_CAP`):
89//   - **overlong** (this is a CANONICAL transform): folded into the value above and
90//     into `normalize_path` — applies pipeline-wide, no per-name exclusion;
91//   - **base64** (a DERIVED channel): `derived_decoded` variants, decode-then-match-
92//     then-discard, gated by `is_base64_candidate` + a per-NAME structural exclusion
93//     on the header surface (Authorization/Cookie/ETag/…). See `expand_base64`.
94
95/// Shared fixed-point decode budget across overlong + base64 (10c). One constant,
96/// not two: an attacker chaining encodings cannot exceed it.
97pub const PIPELINE_CAP: usize = 5;
98
99/// Minimum base64 length to even attempt a decode. A COST gate, not a security gate
100/// (the security gate is decode-then-match): probe-measured floor = 12, the length
101/// of the shortest tracked attack (`base64("\r\nQUIT\r\n")`). Below 12 only adds work.
102pub const BASE64_MIN_LEN: usize = 12;
103
104/// Byte-level percent-decode (single pass). Unlike [`percent_decode`] this keeps the
105/// result as raw bytes — no `from_utf8_lossy` — so overlong sequences survive for
106/// [`collapse_overlong`]. `+`→space only when `plus_as_space` (form-encoding).
107fn percent_decode_bytes(input: &[u8], plus_as_space: bool) -> Vec<u8> {
108    let mut out: Vec<u8> = Vec::with_capacity(input.len());
109    let mut i = 0;
110    while i < input.len() {
111        if input[i] == b'+' && plus_as_space {
112            out.push(b' ');
113            i += 1;
114        } else if input[i] == b'%' && i + 2 < input.len() && is_hex(input[i + 1]) && is_hex(input[i + 2]) {
115            out.push((hex_val(input[i + 1]) << 4) | hex_val(input[i + 2]));
116            i += 3;
117        } else {
118            out.push(input[i]);
119            i += 1;
120        }
121    }
122    out
123}
124
125/// Recursive percent-decode + overlong-collapse to a fixed point, consuming the
126/// shared `budget`. `+`→space only on the FIRST pass (form-encoding; a literal `+`
127/// in decoded content must not keep collapsing). Returns `(bytes, passes_applied)`.
128fn percent_overlong_fixpoint(raw: &[u8], plus_as_space: bool, budget: &mut usize) -> (Vec<u8>, usize) {
129    let mut bytes = raw.to_vec();
130    let mut passes = 0;
131    while *budget > 0 {
132        let next = collapse_overlong(&percent_decode_bytes(&bytes, plus_as_space && passes == 0));
133        if next == bytes {
134            break;
135        }
136        bytes = next;
137        passes += 1;
138        *budget -= 1;
139    }
140    (bytes, passes)
141}
142
143/// Collapse overlong UTF-8 to ASCII and return a lossy string. Pub for §6 docs /
144/// tests; the pipeline uses the byte-level [`collapse_overlong`] inside the fixpoint.
145pub fn decode_overlong_utf8(input: &str) -> String {
146    String::from_utf8_lossy(&collapse_overlong(input.as_bytes())).into_owned()
147}
148
149/// Base64 CANDIDACY (cost gate): strict alphabet `[A-Za-z0-9+/]` + `=` padding,
150/// length a multiple of 4, and `>= len_min`. NOT a security gate — a benign token
151/// that passes still cannot cause an FP because the decoded value only contributes
152/// if it matches a module rule (decode-then-match-then-discard). Probe-verified
153/// `benign_FP=[]` at every threshold.
154pub fn is_base64_candidate(s: &str, len_min: usize) -> bool {
155    if s.len() < len_min {
156        return false;
157    }
158    let core = s.trim_end_matches('=');
159    // base64 quanta encode to 2/3/4 chars per group → a core length of `%4 == 1` is
160    // IMPOSSIBLE; everything else (0/2/3) is valid. We must NOT require `%4 == 0`: that
161    // only holds for PADDED base64, but gotestwaf (and many real encoders) emit UNPADDED
162    // base64 — `%4 ∈ {2,3}` — which the old gate silently rejected, so the decode-then-
163    // match channel never saw the payload (10c REOPEN, pcap-confirmed: 72/106 wire values).
164    !core.is_empty()
165        && core.len() % 4 != 1
166        && core.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/')
167}
168
169/// Hand-rolled standard base64 decode (alphabet `+/`, `=` padding). Returns `None`
170/// on any non-alphabet byte. No new dependency (mirrors the hand-rolled percent /
171/// overlong decoders); property-tested in the differential suite.
172pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
173    fn val(c: u8) -> Option<u8> {
174        match c {
175            b'A'..=b'Z' => Some(c - b'A'),
176            b'a'..=b'z' => Some(c - b'a' + 26),
177            b'0'..=b'9' => Some(c - b'0' + 52),
178            b'+' => Some(62),
179            b'/' => Some(63),
180            _ => None,
181        }
182    }
183    let core = s.trim_end_matches('=');
184    let mut out: Vec<u8> = Vec::with_capacity(core.len() * 3 / 4);
185    let (mut buf, mut bits) = (0u32, 0u32);
186    for &c in core.as_bytes() {
187        buf = (buf << 6) | val(c)? as u32;
188        bits += 6;
189        if bits >= 8 {
190            bits -= 8;
191            out.push((buf >> bits) as u8);
192        }
193    }
194    Some(out)
195}
196
197/// A decoded blob is worth inspecting only if it is mostly text — a random
198/// token/hash decodes to high-entropy bytes (no module signature), so we discard it
199/// before it even reaches the rules. ≥90% printable ASCII (CR/LF/TAB allowed).
200fn mostly_printable(b: &[u8]) -> bool {
201    if b.is_empty() {
202        return false;
203    }
204    let p = b
205        .iter()
206        .filter(|&&c| matches!(c, b'\r' | b'\n' | b'\t') || (0x20..=0x7e).contains(&c))
207        .count();
208    p * 100 / b.len() >= 90
209}
210
211/// Base64-DERIVED variants of `value`, sharing `budget` with the overlong stage.
212/// If `value` is a confident base64 candidate that decodes to mostly-printable text,
213/// canonicalize the decode (so a base64 wrapping a percent/overlong payload still
214/// resolves) and push it; recurse for nested base64. Each derived string is
215/// inspection-only ("discard if it matches nothing"). Caller pre-applies the header
216/// per-name exclusion (this fn is alphabet-only and surface-agnostic).
217pub fn expand_base64(value: &str, budget: &mut usize, out: &mut Vec<String>) {
218    if *budget == 0 || !is_base64_candidate(value, BASE64_MIN_LEN) {
219        return;
220    }
221    let Some(decoded) = base64_decode(value) else { return };
222    if !mostly_printable(&decoded) {
223        return;
224    }
225    *budget -= 1;
226    // The decode may itself carry percent/overlong encodings → canonicalize (shared
227    // budget), then recurse for base64-of-base64.
228    let (bytes, _) = percent_overlong_fixpoint(&decoded, false, budget);
229    let canon: String = String::from_utf8_lossy(&bytes).nfkc().collect();
230    expand_base64(&canon, budget, out);
231    out.push(canon);
232}
233
234/// Collect base64-derived variants from `value` with a FRESH shared budget. The
235/// single entry the normalizer calls per inspected field value.
236pub fn base64_derived(value: &str) -> Vec<String> {
237    let mut out = Vec::new();
238    expand_base64(value, &mut PIPELINE_CAP.clone(), &mut out);
239    out
240}
241
242/// Named HTML entities decoded by the EVASION decoder (§6-D1). DELIBERATELY excludes the
243/// structural/escaping entities `lt`/`gt`/`amp`/`quot`/`apos` — decoding those would
244/// reconstruct `<script>` / `"` from benign HTML-escaped content (forums, code samples,
245/// JSON-carrying-HTML) and FALSE-POSITIVE. The ones here resolve obfuscation that benign
246/// callers never use (`confirm&lpar;1&rpar;`, `&equals;`).
247const ENTITY_NAMED: &[(&str, char)] = &[
248    ("lpar", '('), ("rpar", ')'), ("equals", '='), ("colon", ':'), ("sol", '/'),
249    ("bsol", '\\'), ("period", '.'), ("comma", ','), ("excl", '!'), ("semi", ';'),
250    ("quest", '?'), ("commat", '@'), ("dollar", '$'), ("percnt", '%'), ("plus", '+'),
251    ("ast", '*'), ("midast", '*'), ("lbrace", '{'), ("rbrace", '}'), ("lcub", '{'),
252    ("rcub", '}'), ("lsqb", '['), ("rsqb", ']'), ("grave", '`'), ("lowbar", '_'),
253    ("verbar", '|'), ("vert", '|'), ("num", '#'), ("Tab", '\t'), ("NewLine", '\n'),
254];
255
256/// HTML-entity decode for EVASION only (§6-D1): named (table above) + numeric
257/// (`&#NN;` / `&#xHH;`), but NEVER the 5 structural chars `< > & " '`. Returns `Some`
258/// only when at least one entity was decoded (else the value is unchanged → no point
259/// adding it to the derived channel). decode-then-match-then-discard: this output is an
260/// inspection-only variant, the stored value is untouched. Linear single pass.
261pub fn html_entity_decode_evasion(s: &str) -> Option<String> {
262    if !s.contains('&') {
263        return None;
264    }
265    let mut out = String::with_capacity(s.len());
266    let mut changed = false;
267    let mut rest = s;
268    while let Some(amp) = rest.find('&') {
269        out.push_str(&rest[..amp]);
270        let after = &rest[amp + 1..];
271        // entity body is up to the next ';' within a small window (cap 31 chars)
272        let decoded = after.find(';').filter(|&p| p <= 31).and_then(|semi| {
273            let ent = &after[..semi];
274            let c = if let Some(num) = ent.strip_prefix('#') {
275                let cp = match num.strip_prefix(['x', 'X']) {
276                    Some(hex) => u32::from_str_radix(hex, 16).ok(),
277                    None => num.parse::<u32>().ok(),
278                };
279                cp.and_then(char::from_u32)
280            } else {
281                ENTITY_NAMED.iter().find(|(n, _)| *n == ent).map(|(_, c)| *c)
282            };
283            c.filter(|c| !matches!(c, '<' | '>' | '&' | '"' | '\''))
284                .map(|c| (c, semi))
285        });
286        match decoded {
287            Some((c, semi)) => {
288                out.push(c);
289                changed = true;
290                rest = &after[semi + 1..];
291            }
292            None => {
293                out.push('&');
294                rest = after;
295            }
296        }
297    }
298    out.push_str(rest);
299    changed.then_some(out)
300}
301
302/// MID-TOKEN tag-strip (§6-D2): drop a `<…>` tag ONLY when immediately surrounded by word
303/// chars on BOTH sides (`\w<…>\w`) — i.e. injected INSIDE an identifier to break a token
304/// (`o<x>nfocus` → `onfocus`, `autof<x>ocus` → `autofocus`), the gotestwaf mutation-XSS
305/// evasion. WRAPPING tags (`<code>onerror</code>`, HTML tables/`<b>`/`<a href>`) are left
306/// INTACT, so benign HTML-bearing content gains NO spurious `onerror=` adjacency
307/// (probe-measured: zero new FP). Returns `Some` only when a tag was dropped. The tag span
308/// is capped at 24 chars (a real mutation tag is tiny — `<x>`, `<y>`).
309pub fn strip_midtoken_tags(s: &str) -> Option<String> {
310    if !s.contains('<') {
311        return None;
312    }
313    let b = s.as_bytes();
314    let mut out = String::with_capacity(s.len());
315    let mut changed = false;
316    let mut i = 0;
317    while i < b.len() {
318        if b[i] == b'<' {
319            if let Some(close) = s[i..].find('>').map(|p| i + p) {
320                let before = out.chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_');
321                let after = b.get(close + 1).is_some_and(|&c| (c as char).is_alphanumeric() || c == b'_');
322                if before && after && (close - i) <= 24 {
323                    i = close + 1; // drop the mid-token tag, keep the surrounding word chars
324                    changed = true;
325                    continue;
326                }
327            }
328        }
329        let ch = s[i..].chars().next().unwrap();
330        out.push(ch);
331        i += ch.len_utf8();
332    }
333    changed.then_some(out)
334}
335
336/// MID-TOKEN control-character strip (§6-D2b): drop a run of C0 control bytes injected
337/// INSIDE an identifier to break a keyword (`<<scr\0ipt>` → `<<script>`, the gotestwaf
338/// NUL-split mutation). Like [`strip_midtoken_tags`], it fires ONLY when the control run
339/// sits between word chars on BOTH sides — benign content never carries a NUL mid-word.
340/// `\t`/`\n`/`\r` are EXCLUDED: those are structural whitespace handled elsewhere, and
341/// collapsing intra-token WHITESPACE (`scr ipt`) is the high-FP D2b-2 variant, still
342/// deferred. Returns `Some` only when a control run was dropped. Linear single pass.
343pub fn strip_midtoken_controls(s: &str) -> Option<String> {
344    let is_ctrl = |c: u8| c < 0x20 && c != b'\t' && c != b'\n' && c != b'\r';
345    let b = s.as_bytes();
346    if !b.iter().any(|&c| is_ctrl(c)) {
347        return None;
348    }
349    let mut out = String::with_capacity(s.len());
350    let mut changed = false;
351    let mut i = 0;
352    while i < b.len() {
353        if is_ctrl(b[i]) {
354            let mut j = i;
355            while j < b.len() && is_ctrl(b[j]) {
356                j += 1;
357            }
358            let before = out.chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_');
359            let after = b.get(j).is_some_and(|&c| (c as char).is_alphanumeric() || c == b'_');
360            if before && after {
361                i = j; // drop the mid-token control run, keep the surrounding word chars
362                changed = true;
363                continue;
364            }
365        }
366        let ch = s[i..].chars().next().unwrap();
367        out.push(ch);
368        i += ch.len_utf8();
369    }
370    changed.then_some(out)
371}
372
373/// VBScript string-concat de-obfuscation (§6-D3): collapse the `"…&…"` joints VBScript
374/// uses to split keywords across string literals — `"Ex"&"e"&"cute` → `"Execute`,
375/// `M"&"i"&"d` → `Mid`, `c"&"h"&"r` → `chr` (gotestwaf rce-urlparam webshell). Matches a
376/// close-quote, optional ws, `&`, optional ws, open-quote and drops all of it, joining the
377/// adjacent literals. `&` is VBScript's concat operator (JS uses `+`), so `"x"&"y"` in a
378/// request value is itself a strong VBScript tell → low FP, and decode-then-match-then-
379/// discard means it only counts if it reconstructs an RCE keyword. Returns `Some` only when
380/// a joint was removed. Linear single pass.
381pub fn strip_vbscript_concat(s: &str) -> Option<String> {
382    if !s.contains('&') {
383        return None;
384    }
385    let b = s.as_bytes();
386    let ws = |c: u8| c == b' ' || c == b'\t';
387    let mut out = String::with_capacity(s.len());
388    let mut changed = false;
389    let mut i = 0;
390    while i < b.len() {
391        if b[i] == b'"' {
392            let mut j = i + 1;
393            while j < b.len() && ws(b[j]) {
394                j += 1;
395            }
396            if j < b.len() && b[j] == b'&' {
397                let mut k = j + 1;
398                while k < b.len() && ws(b[k]) {
399                    k += 1;
400                }
401                if k < b.len() && b[k] == b'"' {
402                    i = k + 1; // drop the `"…&…"` joint, fusing the two string literals
403                    changed = true;
404                    continue;
405                }
406            }
407        }
408        let ch = s[i..].chars().next().unwrap();
409        out.push(ch);
410        i += ch.len_utf8();
411    }
412    changed.then_some(out)
413}
414
415/// All derived inspection variants of one inspected value (§6): base64-decoded (10c) +
416/// HTML-entity-decoded (evasion, §6-D1) + mid-token-tag-stripped (mutation, §6-D2) +
417/// mid-token-control-stripped (§6-D2b). All are decode-then-match-then-discard. Single
418/// entry the normalizer calls per inspected value.
419///
420/// COMPOSITION (10c reopen): the evasion may live INSIDE a base64 blob (Base64Flat —
421/// gotestwaf wraps the whole mutation/entity payload in base64). The raw `value` is then
422/// the opaque base64 alphabet (no `&`/`<`/control byte), so the entity/tag/control
423/// transforms would no-op on it. Apply them over each base64-DECODED variant too, not
424/// just the raw input — otherwise `<<scr\0ipt>` / `o<x>nfocus` / `confirm&lpar;` survive
425/// the base64 unwrap un-reconstructed (pcap bypass-new.txt: D2a/D2b Base64Flat).
426pub fn derive_variants(value: &str) -> Vec<String> {
427    let mut out = base64_derived(value);
428    // Compose the structural transforms over the base64-decoded variants.
429    let mut composed = Vec::new();
430    for d in &out {
431        if let Some(ent) = html_entity_decode_evasion(d) {
432            composed.push(ent);
433        }
434        if let Some(stripped) = strip_midtoken_tags(d) {
435            composed.push(stripped);
436        }
437        if let Some(stripped) = strip_midtoken_controls(d) {
438            composed.push(stripped);
439        }
440        if let Some(joined) = strip_vbscript_concat(d) {
441            composed.push(joined);
442        }
443    }
444    out.extend(composed);
445    // Raw-surface transforms (URL/percent-decoded value, no base64 wrapper).
446    if let Some(ent) = html_entity_decode_evasion(value) {
447        out.extend(base64_derived(&ent));
448        out.push(ent);
449    }
450    if let Some(stripped) = strip_midtoken_tags(value) {
451        out.push(stripped);
452    }
453    if let Some(stripped) = strip_midtoken_controls(value) {
454        out.push(stripped);
455    }
456    if let Some(joined) = strip_vbscript_concat(value) {
457        out.push(joined);
458    }
459    out
460}
461
462/// Derived inspection variants of a single JSON STRING leaf (Fase 10c).
463///
464/// `serde_json::from_str` already unescapes JSON `\uXXXX`/`\n`/… so `raw` is the
465/// unescaped leaf — but it is stored AND inspected raw: unlike form-urlencoded (decoded
466/// at parse) and multipart (decoded in `body_str_values`), the JSON leaf never sees
467/// `canonicalize_value`. So an encoded leaf (`%25C0%25AE…`, `%3CsvG…`) reaches the
468/// modules still encoded → bypass (pcap 10c). This feeds the DECODED form to the derived
469/// channel instead of mutating the stored leaf (decode-then-match-then-discard):
470///   - percent + overlong fixed-point → the `canonical`, pushed ONLY when it differs
471///     from `raw` (when unchanged, `body_str_values` already inspects the raw leaf, so
472///     pushing would be redundant — `all_matches` dedups by rule anyway);
473///   - base64 expansion of the canonical.
474///
475/// Both stages share the ONE [`PIPELINE_CAP`] budget — no new cap (same invariant as
476/// the overlong/base64 channels). Recursion across nesting levels is automatic: the
477/// caller iterates EVERY flattened leaf (`flatten_json` descends objects + arrays).
478pub fn json_leaf_derived(raw: &str) -> Vec<String> {
479    let mut budget = PIPELINE_CAP;
480    let mut out = Vec::new();
481    let (bytes, _) = percent_overlong_fixpoint(raw.as_bytes(), false, &mut budget);
482    let canonical: String = String::from_utf8_lossy(&bytes).nfkc().collect();
483    expand_base64(&canonical, &mut budget, &mut out);
484    // §6-D1: a JSON leaf may carry HTML entities too (`{"q":"confirm&lpar;1&rpar;"}`).
485    if let Some(ent) = html_entity_decode_evasion(&canonical) {
486        out.push(ent);
487    }
488    if canonical != raw {
489        out.push(canonical);
490    }
491    out
492}
493
494/// Collapse **overlong** 2-byte UTF-8 sequences that encode a 7-bit ASCII byte
495/// back to that byte: `0xC0 0xAE` → `.`, `0xC0 0xAF` → `/`, `0xC1 …` → the
496/// corresponding char. These are illegal UTF-8 (a `.`/`/` must be a single byte),
497/// so a normal decode maps them to U+FFFD and the `../` / `/etc/passwd` signature
498/// is lost — the classic overlong path-traversal evasion. Lead bytes `0xC0`/`0xC1`
499/// can ONLY introduce an overlong (codepoint < 0x80), so mapping them is sound.
500fn collapse_overlong(bytes: &[u8]) -> Vec<u8> {
501    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
502    let mut i = 0;
503    while i < bytes.len() {
504        let b = bytes[i];
505        if (b == 0xC0 || b == 0xC1) && i + 1 < bytes.len() && (0x80..=0xBF).contains(&bytes[i + 1]) {
506            // cp = ((b & 0x1F) << 6) | (b2 & 0x3F); always < 0x80 for 0xC0/0xC1.
507            out.push(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
508            i += 2;
509        } else {
510            out.push(b);
511            i += 1;
512        }
513    }
514    out
515}
516
517/// Canonicalize a multipart field (name / filename / value). Since 10c folded the
518/// recursive percent + overlong decode into [`canonicalize_value`] PIPELINE-WIDE,
519/// this is just that canonical transform (multipart `+` is literal). Kept as a named
520/// entry for the multipart call sites (10b-cont). Base64-derived variants from
521/// multipart values are collected separately by the normalizer (`base64_derived`).
522pub fn canonicalize_multipart_field(raw: &str) -> String {
523    canonicalize_value(raw, false).0
524}
525
526/// Normalize a URL path.
527///
528/// Steps:
529/// 1. Percent-decode (detecting double-encoding).
530/// 2. If double-encoded, decode the result a second time.
531/// 3. NFKC Unicode normalization (fullwidth → ASCII, ligatures → components).
532/// 4. Strip null bytes.
533/// 5. Lowercase.
534/// 6. Resolve `.` / `..` segments and collapse consecutive slashes.
535///
536/// Returns `(normalized_path, double_encoding_detected)`.
537pub fn normalize_path(raw: &str) -> (String, bool) {
538    // 10c: recursive percent + overlong fixpoint (shared cap), then NFKC / strip
539    // NUL / lowercase / resolve. Overlong now collapses on the path too (`%C0%AE`→`.`).
540    let mut budget = PIPELINE_CAP;
541    let (bytes, passes) = percent_overlong_fixpoint(raw.as_bytes(), false, &mut budget);
542    let nfkc: String = String::from_utf8_lossy(&bytes).nfkc().collect();
543    let no_nulls: String = nfkc.chars().filter(|&c| c != '\0').collect();
544    let lower = no_nulls.to_lowercase();
545    let resolved = resolve_path(&lower);
546
547    (resolved, passes >= 2)
548}
549
550fn resolve_path(path: &str) -> String {
551    let mut segments: Vec<&str> = Vec::new();
552
553    for seg in path.split('/') {
554        match seg {
555            "" | "." => {}
556            ".." => { segments.pop(); }
557            other => segments.push(other),
558        }
559    }
560
561    let mut out = String::with_capacity(path.len().max(1));
562    for seg in &segments {
563        out.push('/');
564        out.push_str(seg);
565    }
566    if out.is_empty() {
567        out.push('/');
568    }
569    out
570}
571
572/// Parsed query result: `(params, double_encoding_detected, derived_decoded)`.
573pub type ParsedQuery = (Vec<(String, String)>, bool, Vec<String>);
574
575/// Parse a query string into decoded key-value pairs (`+` treated as space).
576///
577/// Values are fully canonicalized (percent + overlong fixpoint + NFKC). Also returns
578/// the **base64-derived** variants of the values (10c, decode-then-match-then-discard).
579/// Returns `(params, double_encoding_detected, derived_decoded)`.
580pub fn parse_query(
581    query: &str,
582    limits: &LimitsConfig,
583) -> Result<ParsedQuery, NormalizationError> {
584    let mut params = Vec::new();
585    let mut double_enc = false;
586    let mut derived = Vec::new();
587
588    for pair in query.split('&') {
589        if pair.is_empty() {
590            continue;
591        }
592        if params.len() >= limits.max_params {
593            return Err(NormalizationError::TooManyParams { limit: limits.max_params });
594        }
595        let (k, v) = match pair.find('=') {
596            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
597            None => (pair, ""),
598        };
599        let (dk, de_k) = canonicalize_value(k, true);
600        let (dv, de_v) = canonicalize_value(v, true);
601        if de_k || de_v {
602            double_enc = true;
603        }
604        // Base64-derived from the canonical VALUE only (param names aren't attacker
605        // payload carriers; keys stay out, like multipart field names).
606        derived.extend(derive_variants(&dv));
607        params.push((dk, dv));
608    }
609
610    Ok((params, double_enc, derived))
611}
612
613/// Parse a Cookie header value into name-value pairs, enforcing `max_cookies`.
614pub fn parse_cookies_limited(
615    cookie_header: &str,
616    max_cookies: usize,
617) -> Result<Vec<(String, String)>, NormalizationError> {
618    let mut cookies = Vec::new();
619
620    for pair in cookie_header.split(';') {
621        let pair = pair.trim();
622        if pair.is_empty() {
623            continue;
624        }
625        if cookies.len() >= max_cookies {
626            return Err(NormalizationError::TooManyCookies { limit: max_cookies });
627        }
628        let (k, v) = match pair.find('=') {
629            Some(pos) => (pair[..pos].trim(), pair[pos + 1..].trim()),
630            None => (pair, ""),
631        };
632        cookies.push((k.to_string(), v.to_string()));
633    }
634
635    Ok(cookies)
636}