use unicode_normalization::UnicodeNormalization;
use waf_core::LimitsConfig;
use crate::NormalizationError;
fn is_hex(b: u8) -> bool {
b.is_ascii_hexdigit()
}
fn hex_val(b: u8) -> u8 {
match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
}
}
fn still_percent_encoded(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
while i + 2 < b.len() {
if b[i] == b'%' && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
return true;
}
i += 1;
}
false
}
pub fn percent_decode(input: &str, plus_as_space: bool) -> (String, bool) {
let b = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'+' && plus_as_space {
out.push(b' ');
i += 1;
} else if b[i] == b'%' && i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
out.push((hex_val(b[i + 1]) << 4) | hex_val(b[i + 2]));
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
let decoded = String::from_utf8_lossy(&out).into_owned();
let double_enc = still_percent_encoded(&decoded);
(decoded, double_enc)
}
pub fn canonicalize_value(raw: &str, plus_as_space: bool) -> (String, bool) {
let mut budget = PIPELINE_CAP;
let (bytes, passes) = percent_overlong_fixpoint(raw.as_bytes(), plus_as_space, &mut budget);
let canonical: String = String::from_utf8_lossy(&bytes).nfkc().collect();
(canonical, passes >= 2)
}
pub const PIPELINE_CAP: usize = 5;
pub const BASE64_MIN_LEN: usize = 12;
fn percent_decode_bytes(input: &[u8], plus_as_space: bool) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == b'+' && plus_as_space {
out.push(b' ');
i += 1;
} else if input[i] == b'%' && i + 2 < input.len() && is_hex(input[i + 1]) && is_hex(input[i + 2]) {
out.push((hex_val(input[i + 1]) << 4) | hex_val(input[i + 2]));
i += 3;
} else {
out.push(input[i]);
i += 1;
}
}
out
}
fn percent_overlong_fixpoint(raw: &[u8], plus_as_space: bool, budget: &mut usize) -> (Vec<u8>, usize) {
let mut bytes = raw.to_vec();
let mut passes = 0;
while *budget > 0 {
let next = collapse_overlong(&percent_decode_bytes(&bytes, plus_as_space && passes == 0));
if next == bytes {
break;
}
bytes = next;
passes += 1;
*budget -= 1;
}
(bytes, passes)
}
pub fn decode_overlong_utf8(input: &str) -> String {
String::from_utf8_lossy(&collapse_overlong(input.as_bytes())).into_owned()
}
pub fn is_base64_candidate(s: &str, len_min: usize) -> bool {
if s.len() < len_min {
return false;
}
let core = s.trim_end_matches('=');
!core.is_empty()
&& core.len() % 4 != 1
&& core.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/')
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
fn val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let core = s.trim_end_matches('=');
let mut out: Vec<u8> = Vec::with_capacity(core.len() * 3 / 4);
let (mut buf, mut bits) = (0u32, 0u32);
for &c in core.as_bytes() {
buf = (buf << 6) | val(c)? as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
Some(out)
}
fn mostly_printable(b: &[u8]) -> bool {
if b.is_empty() {
return false;
}
let p = b
.iter()
.filter(|&&c| matches!(c, b'\r' | b'\n' | b'\t') || (0x20..=0x7e).contains(&c))
.count();
p * 100 / b.len() >= 90
}
pub fn expand_base64(value: &str, budget: &mut usize, out: &mut Vec<String>) {
if *budget == 0 || !is_base64_candidate(value, BASE64_MIN_LEN) {
return;
}
let Some(decoded) = base64_decode(value) else { return };
if !mostly_printable(&decoded) {
return;
}
*budget -= 1;
let (bytes, _) = percent_overlong_fixpoint(&decoded, false, budget);
let canon: String = String::from_utf8_lossy(&bytes).nfkc().collect();
expand_base64(&canon, budget, out);
out.push(canon);
}
pub fn base64_derived(value: &str) -> Vec<String> {
let mut out = Vec::new();
expand_base64(value, &mut PIPELINE_CAP.clone(), &mut out);
out
}
const ENTITY_NAMED: &[(&str, char)] = &[
("lpar", '('), ("rpar", ')'), ("equals", '='), ("colon", ':'), ("sol", '/'),
("bsol", '\\'), ("period", '.'), ("comma", ','), ("excl", '!'), ("semi", ';'),
("quest", '?'), ("commat", '@'), ("dollar", '$'), ("percnt", '%'), ("plus", '+'),
("ast", '*'), ("midast", '*'), ("lbrace", '{'), ("rbrace", '}'), ("lcub", '{'),
("rcub", '}'), ("lsqb", '['), ("rsqb", ']'), ("grave", '`'), ("lowbar", '_'),
("verbar", '|'), ("vert", '|'), ("num", '#'), ("Tab", '\t'), ("NewLine", '\n'),
];
pub fn html_entity_decode_evasion(s: &str) -> Option<String> {
if !s.contains('&') {
return None;
}
let mut out = String::with_capacity(s.len());
let mut changed = false;
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp + 1..];
let decoded = after.find(';').filter(|&p| p <= 31).and_then(|semi| {
let ent = &after[..semi];
let c = if let Some(num) = ent.strip_prefix('#') {
let cp = match num.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => num.parse::<u32>().ok(),
};
cp.and_then(char::from_u32)
} else {
ENTITY_NAMED.iter().find(|(n, _)| *n == ent).map(|(_, c)| *c)
};
c.filter(|c| !matches!(c, '<' | '>' | '&' | '"' | '\''))
.map(|c| (c, semi))
});
match decoded {
Some((c, semi)) => {
out.push(c);
changed = true;
rest = &after[semi + 1..];
}
None => {
out.push('&');
rest = after;
}
}
}
out.push_str(rest);
changed.then_some(out)
}
pub fn strip_midtoken_tags(s: &str) -> Option<String> {
if !s.contains('<') {
return None;
}
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut changed = false;
let mut i = 0;
while i < b.len() {
if b[i] == b'<' {
if let Some(close) = s[i..].find('>').map(|p| i + p) {
let before = out.chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_');
let after = b.get(close + 1).is_some_and(|&c| (c as char).is_alphanumeric() || c == b'_');
if before && after && (close - i) <= 24 {
i = close + 1; changed = true;
continue;
}
}
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
changed.then_some(out)
}
pub fn strip_midtoken_controls(s: &str) -> Option<String> {
let is_ctrl = |c: u8| c < 0x20 && c != b'\t' && c != b'\n' && c != b'\r';
let b = s.as_bytes();
if !b.iter().any(|&c| is_ctrl(c)) {
return None;
}
let mut out = String::with_capacity(s.len());
let mut changed = false;
let mut i = 0;
while i < b.len() {
if is_ctrl(b[i]) {
let mut j = i;
while j < b.len() && is_ctrl(b[j]) {
j += 1;
}
let before = out.chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_');
let after = b.get(j).is_some_and(|&c| (c as char).is_alphanumeric() || c == b'_');
if before && after {
i = j; changed = true;
continue;
}
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
changed.then_some(out)
}
pub fn strip_vbscript_concat(s: &str) -> Option<String> {
if !s.contains('&') {
return None;
}
let b = s.as_bytes();
let ws = |c: u8| c == b' ' || c == b'\t';
let mut out = String::with_capacity(s.len());
let mut changed = false;
let mut i = 0;
while i < b.len() {
if b[i] == b'"' {
let mut j = i + 1;
while j < b.len() && ws(b[j]) {
j += 1;
}
if j < b.len() && b[j] == b'&' {
let mut k = j + 1;
while k < b.len() && ws(b[k]) {
k += 1;
}
if k < b.len() && b[k] == b'"' {
i = k + 1; changed = true;
continue;
}
}
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
changed.then_some(out)
}
pub fn derive_variants(value: &str) -> Vec<String> {
let mut out = base64_derived(value);
let mut composed = Vec::new();
for d in &out {
if let Some(ent) = html_entity_decode_evasion(d) {
composed.push(ent);
}
if let Some(stripped) = strip_midtoken_tags(d) {
composed.push(stripped);
}
if let Some(stripped) = strip_midtoken_controls(d) {
composed.push(stripped);
}
if let Some(joined) = strip_vbscript_concat(d) {
composed.push(joined);
}
}
out.extend(composed);
if let Some(ent) = html_entity_decode_evasion(value) {
out.extend(base64_derived(&ent));
out.push(ent);
}
if let Some(stripped) = strip_midtoken_tags(value) {
out.push(stripped);
}
if let Some(stripped) = strip_midtoken_controls(value) {
out.push(stripped);
}
if let Some(joined) = strip_vbscript_concat(value) {
out.push(joined);
}
out
}
pub fn json_leaf_derived(raw: &str) -> Vec<String> {
let mut budget = PIPELINE_CAP;
let mut out = Vec::new();
let (bytes, _) = percent_overlong_fixpoint(raw.as_bytes(), false, &mut budget);
let canonical: String = String::from_utf8_lossy(&bytes).nfkc().collect();
expand_base64(&canonical, &mut budget, &mut out);
if let Some(ent) = html_entity_decode_evasion(&canonical) {
out.push(ent);
}
if canonical != raw {
out.push(canonical);
}
out
}
fn collapse_overlong(bytes: &[u8]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if (b == 0xC0 || b == 0xC1) && i + 1 < bytes.len() && (0x80..=0xBF).contains(&bytes[i + 1]) {
out.push(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
i += 2;
} else {
out.push(b);
i += 1;
}
}
out
}
pub fn canonicalize_multipart_field(raw: &str) -> String {
canonicalize_value(raw, false).0
}
pub fn normalize_path(raw: &str) -> (String, bool) {
let mut budget = PIPELINE_CAP;
let (bytes, passes) = percent_overlong_fixpoint(raw.as_bytes(), false, &mut budget);
let nfkc: String = String::from_utf8_lossy(&bytes).nfkc().collect();
let no_nulls: String = nfkc.chars().filter(|&c| c != '\0').collect();
let lower = no_nulls.to_lowercase();
let resolved = resolve_path(&lower);
(resolved, passes >= 2)
}
fn resolve_path(path: &str) -> String {
let mut segments: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => { segments.pop(); }
other => segments.push(other),
}
}
let mut out = String::with_capacity(path.len().max(1));
for seg in &segments {
out.push('/');
out.push_str(seg);
}
if out.is_empty() {
out.push('/');
}
out
}
pub type ParsedQuery = (Vec<(String, String)>, bool, Vec<String>);
pub fn parse_query(
query: &str,
limits: &LimitsConfig,
) -> Result<ParsedQuery, NormalizationError> {
let mut params = Vec::new();
let mut double_enc = false;
let mut derived = Vec::new();
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
if params.len() >= limits.max_params {
return Err(NormalizationError::TooManyParams { limit: limits.max_params });
}
let (k, v) = match pair.find('=') {
Some(pos) => (&pair[..pos], &pair[pos + 1..]),
None => (pair, ""),
};
let (dk, de_k) = canonicalize_value(k, true);
let (dv, de_v) = canonicalize_value(v, true);
if de_k || de_v {
double_enc = true;
}
derived.extend(derive_variants(&dv));
params.push((dk, dv));
}
Ok((params, double_enc, derived))
}
pub fn parse_cookies_limited(
cookie_header: &str,
max_cookies: usize,
) -> Result<Vec<(String, String)>, NormalizationError> {
let mut cookies = Vec::new();
for pair in cookie_header.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if cookies.len() >= max_cookies {
return Err(NormalizationError::TooManyCookies { limit: max_cookies });
}
let (k, v) = match pair.find('=') {
Some(pos) => (pair[..pos].trim(), pair[pos + 1..].trim()),
None => (pair, ""),
};
cookies.push((k.to_string(), v.to_string()));
}
Ok(cookies)
}