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(1)`, `=`).
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(` 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(1)"}`).
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}