use waf_normalizer::url::{base64_decode, percent_decode};
use super::ast::Transform;
pub fn apply(input: &str, pipeline: &[Transform]) -> String {
let mut s = input.to_string();
for t in pipeline {
s = apply_one(&s, t);
}
s
}
fn apply_one(s: &str, t: &Transform) -> String {
match t {
Transform::None | Transform::Unsupported(_) => s.to_string(),
Transform::Lowercase => s.to_ascii_lowercase(),
Transform::UrlDecode => percent_decode(s, true).0,
Transform::UrlDecodeUni => percent_decode(&decode_u_escapes(s), true).0,
Transform::HtmlEntityDecode => html_entity_decode(s),
Transform::CompressWhitespace => compress_whitespace(s),
Transform::RemoveWhitespace => s.chars().filter(|c| !c.is_whitespace()).collect(),
Transform::RemoveNulls => s.replace('\0', ""),
Transform::NormalizePath => normalize_path(s),
Transform::Base64Decode => match base64_decode(s.trim()) {
Some(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
None => s.to_string(),
},
}
}
fn compress_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for c in s.chars() {
if c.is_whitespace() {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
out.push(c);
in_ws = false;
}
}
out
}
fn decode_u_escapes(s: &str) -> String {
let b = s.as_bytes();
let n = b.len();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < n {
if b[i] == b'%' && i + 6 <= n && (b[i + 1] == b'u' || b[i + 1] == b'U') {
let hex = &s[i + 2..i + 6];
if let Ok(cp) = u32::from_str_radix(hex, 16) {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
i += 6;
continue;
}
}
}
let ch = s[i..].chars().next().unwrap_or('\u{FFFD}');
out.push(ch);
i += ch.len_utf8();
}
out
}
fn html_entity_decode(s: &str) -> String {
let b = s.as_bytes();
let n = b.len();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < n {
if b[i] == b'&' {
if let Some((decoded, consumed)) = decode_entity(&s[i..]) {
out.push(decoded);
i += consumed;
continue;
}
}
let ch = s[i..].chars().next().unwrap_or('\u{FFFD}');
out.push(ch);
i += ch.len_utf8();
}
out
}
fn decode_entity(rest: &str) -> Option<(char, usize)> {
let bytes = rest.as_bytes();
if bytes.len() < 3 {
return None;
}
if bytes[1] == b'#' {
let (radix, start) = if bytes.len() > 2 && (bytes[2] == b'x' || bytes[2] == b'X') {
(16, 3)
} else {
(10, 2)
};
let mut j = start;
while j < bytes.len() && j < start + 8 && is_radix_digit(bytes[j], radix) {
j += 1;
}
if j == start {
return None;
}
let cp = u32::from_str_radix(&rest[start..j], radix).ok()?;
let ch = char::from_u32(cp)?;
let consumed = if j < bytes.len() && bytes[j] == b';' { j + 1 } else { j };
return Some((ch, consumed));
}
let mut j = 1;
while j < bytes.len() && j < 11 && bytes[j].is_ascii_alphabetic() {
j += 1;
}
let name = &rest[1..j];
let ch = named_entity(name)?;
let consumed = if j < bytes.len() && bytes[j] == b';' { j + 1 } else { j };
Some((ch, consumed))
}
fn named_entity(name: &str) -> Option<char> {
Some(match name {
"lt" => '<',
"gt" => '>',
"amp" => '&',
"quot" => '"',
"apos" => '\'',
"nbsp" => '\u{00A0}',
"tab" => '\t',
"newline" => '\n',
"sol" => '/',
"bsol" => '\\',
"colon" => ':',
"semi" => ';',
"lpar" => '(',
"rpar" => ')',
"equals" => '=',
_ => return None,
})
}
fn is_radix_digit(b: u8, radix: u32) -> bool {
if radix == 16 {
b.is_ascii_hexdigit()
} else {
b.is_ascii_digit()
}
}
fn normalize_path(s: &str) -> String {
let leading_slash = s.starts_with('/');
let mut stack: Vec<&str> = Vec::new();
for seg in s.split('/') {
match seg {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
let joined = stack.join("/");
let trailing_slash = s.ends_with('/') && !joined.is_empty();
let mut out = String::new();
if leading_slash {
out.push('/');
}
out.push_str(&joined);
if trailing_slash {
out.push('/');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn t(input: &str, pipeline: &[Transform]) -> String {
apply(input, pipeline)
}
#[test]
fn lowercase() {
assert_eq!(t("UnIoN", &[Transform::Lowercase]), "union");
}
#[test]
fn url_decode_single_pass() {
assert_eq!(t("%75nion", &[Transform::UrlDecode]), "union");
assert_eq!(t("%2575nion", &[Transform::UrlDecode]), "%75nion");
}
#[test]
fn url_decode_plus_is_space() {
assert_eq!(t("a+b", &[Transform::UrlDecode]), "a b");
}
#[test]
fn url_decode_uni() {
assert_eq!(t("%u0041BC", &[Transform::UrlDecodeUni]), "ABC");
}
#[test]
fn html_entity_numeric_and_named() {
assert_eq!(t("<script>", &[Transform::HtmlEntityDecode]), "<script>");
assert_eq!(t("<a>", &[Transform::HtmlEntityDecode]), "<a>");
assert_eq!(t("<b>", &[Transform::HtmlEntityDecode]), "<b>");
assert_eq!(t("<script", &[Transform::HtmlEntityDecode]), "<script");
}
#[test]
fn compress_and_remove_whitespace() {
assert_eq!(t("a \t b", &[Transform::CompressWhitespace]), "a b");
assert_eq!(t("a \t b", &[Transform::RemoveWhitespace]), "ab");
}
#[test]
fn remove_nulls() {
assert_eq!(t("a\0b", &[Transform::RemoveNulls]), "ab");
}
#[test]
fn normalize_path_resolves_dotdot() {
assert_eq!(t("/a/b/../c", &[Transform::NormalizePath]), "/a/c");
assert_eq!(t("/a//./b/", &[Transform::NormalizePath]), "/a/b/");
assert_eq!(t("/Etc/Passwd", &[Transform::NormalizePath]), "/Etc/Passwd");
}
#[test]
fn base64_decode() {
assert_eq!(t("dW5pb24gc2VsZWN0", &[Transform::Base64Decode]), "union select");
}
#[test]
fn pipeline_order_matters() {
let p = [Transform::UrlDecode, Transform::Lowercase];
assert_eq!(t("%55nion", &p), "union");
}
}