Skip to content
Web Cache Deception

Web Cache Deception and Cache Poisoning, Explained: When the Cache Reads a URL Differently

A cache that reads a URL differently from the application behind it can store a signed-in user's private page for anyone to fetch, or replay one attacker's header to every visitor. Here is how cache keys and path confusion cause both, and how to make the two layers agree.

Part 1 of 1From the Web Cache Deception series

At a glance

CWE-524
Interpreter
Shared HTTP cache (CDN, reverse proxy) and the origin's router
Required condition
The cache and the origin classify the same URL differently, or an input the cache does not key changes the response.
Potential impact
Private pages and tokens served to anonymous users, or attacker-chosen content served to every visitor.
Primary defense
Mark dynamic responses no-store and private, stop edge rules from overriding them, and key or strip every input that shapes a response.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

A shared cache, whether a CDN, a reverse proxy such as nginx or Varnish, or a framework's output cache, answers repeat requests without asking the origin. It decides on its own whether a response may be stored and which later requests may receive it. Web cache attacks live in the gap between those decisions and what the origin actually did.

There are two classes. Web cache deception tricks the cache into storing a signed-in user's private page under a static-looking URL that anyone can fetch afterward. Web cache poisoning sends an input that the application reflects but the cache leaves out of its key, so one attacker's value is replayed to everyone requesting that URL. SelfSec reports the first as CWE-524, use of a cache containing sensitive information, and the second as CWE-349, acceptance of extraneous untrusted data with trusted data, both at high severity.

The mental model: two parsers, one URL

The cache key is the information a cache uses to choose a stored response. RFC 9111 says it is composed, at a minimum, of the request method and target URI. Many caches also decide what to store from the URL alone, with rules such as "cache everything ending in .css or .js". The origin resolves the same URL through its own router, which may read it quite differently.

Request URL What an extension rule concludes What a lenient origin returns
/account Dynamic page, do not store The account page
/account.css Stylesheet, store it The account page, when .css is read as a format suffix
/account/ssecpoc.css Stylesheet, store it The account page, when the route ignores trailing segments
/account;ssecpoc.css Stylesheet, since ; is an ordinary character The account page, when ; starts a path parameter

Every row below the first is a private response with a public-looking name. Poisoning is the same disagreement applied to headers instead of paths: the origin reads X-Forwarded-Host, and the cache key does not.

How it happens

Deception needs two decisions to line up. First, the edge caches by extension and overrides the origin's headers:

location ~* \.(css|js)$ {
    proxy_pass http://app_backend;
    proxy_cache static;
    proxy_ignore_headers Cache-Control Set-Cookie;
    proxy_cache_valid 200 1h;
}

Second, the origin is lenient about what it treats as /account. Java servlet containers remove ;name path parameters before mapping a request, which is how ;jsessionid= works. Spring MVC before 5.3 matched /account.css to the /account handler through suffix pattern matching, and a Django re_path(r'^account') without an end anchor matches account.css. Combined with the edge rule, the account page becomes a stylesheet.

Poisoning needs an unkeyed input that changes the response. An application that builds absolute URLs from X-Forwarded-Host writes the header's value into links or script imports while the cache keys only on method and URL. The Host header cache poisoning post walks through that sink in detail.

A concrete test: prove storage, not reflection

An authorized tester needs a test account, a genuinely private page, and a cache-buster: a unique query parameter that keeps every probe on a cache entry no real user will request. First, fetch /account signed in and anonymously, and pick a distinctive string, such as an account reference, that appears only in the signed-in copy. Then request a confused URL with the test session:

GET /account/ssecpoc.css?ssec_cb=k3v9q2m7x1d4 HTTP/1.1
Host: shop.example
Cookie: session=<test-account-session>

If the origin returns the account page, send the identical URL with no cookie:

GET /account/ssecpoc.css?ssec_cb=k3v9q2m7x1d4 HTTP/1.1
Host: shop.example
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Age: 3
X-Cache: HIT

The anonymous response contains the signed-in marker and carries a cache hit. That combination is the proof: the private bytes came from the cache, not from a fresh render. Without a hit indicator the marker is only a lead, since some caches do not announce hits, and an origin that renders private data for a request with no session has an access control problem rather than a caching one.

Poisoning is tested the same way, with a unique hostname under a reserved domain that can never resolve:

GET /?ssec_cb=p8w2n5c0r6t1 HTTP/1.1
Host: shop.example
X-Forwarded-Host: ssec4k9w2p7d1x0a.cachetest.invalid

If the hostname comes back in the body or a response header, repeat the request without X-Forwarded-Host. A reflection alone affects only the tester; the finding is the clean request still carrying the hostname with a cache-hit indicator.

The cache did exactly what its rules said. It simply understood the URL differently from the application behind it.

What an attacker gains

Deception turns one click into a data leak. The attacker sends a signed-in victim a link to /account/anything.css; a top-level navigation carries SameSite=Lax cookies, so the victim's page is stored, and the attacker fetches it anonymously:

  • Personal data from profile, order and billing pages.
  • Tokens embedded in the page, such as anti-forgery tokens that unlock cross-site request forgery, or API keys shown in account settings.

Poisoning reaches everyone who shares the cache key:

  • Script injection at scale through a poisoned script import or attribute, delivered to every visitor as stored XSS.
  • Redirect and link hijacking, including reset and login links built from the poisoned host.
  • Denial of service, when an unkeyed header provokes an error page that gets cached.

A cache can also be poisoned without any unkeyed header when two servers frame requests differently; see HTTP request smuggling.

The fix: let the origin decide, and make the cache listen

The durable fix has three parts. The origin marks every dynamic response Cache-Control: no-store, private unless it is deliberately public, the cache honors that header and stores only content designed to be static, and every input that shapes a response is either keyed or removed before it reaches the application.

In ASP.NET Core, make no-store the default after static files, so a public page must opt in with its own Cache-Control:

app.UseStaticFiles();

app.Use(async (context, next) =>
{
    context.Response.Headers.CacheControl = "no-store, private";
    await next(context);
});

app.UseAuthentication();
app.UseAuthorization();

Keep AllowedHosts in appsettings.json set to your real hostnames, and leave ForwardedHeaders.XForwardedHost out of the forwarded headers middleware unless a proxy you control sets it and ForwardedHeadersOptions.AllowedHosts restricts it.

In Django, never_cache adds Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private; outermost, it also covers the login redirect:

from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.views.decorators.cache import never_cache

@never_cache
@login_required
def account(request):
    return render(request, "account.html")

Route it with path("account", account), which matches exactly, and keep ALLOWED_HOSTS explicit with USE_X_FORWARDED_HOST = False so request.get_host() and build_absolute_uri() never take a client's forwarding header.

At the edge, cache a directory you own rather than an extension, honor the origin's headers, and drop client-settable forwarding headers:

proxy_cache_path /var/cache/nginx/assets keys_zone=assets:10m max_size=1g;

upstream app_backend {
    server 10.0.0.11:8080;
}

server {
    listen 443 ssl;
    server_name shop.example;
    ssl_certificate     /etc/nginx/tls/shop.example.crt;
    ssl_certificate_key /etc/nginx/tls/shop.example.key;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host "";
    proxy_set_header X-Forwarded-Server "";
    proxy_set_header X-Host "";
    proxy_set_header X-Host-Override "";
    proxy_set_header Forwarded "";

    location /assets/ {
        proxy_pass http://app_backend;
        proxy_cache assets;
        proxy_cache_key $scheme$host$request_uri;
        proxy_cache_valid 200 10m;
    }

    location / {
        proxy_pass http://app_backend;
    }
}

Only /assets/ is ever stored, so /account;ssecpoc.css falls through to the uncached location. No proxy_ignore_headers appears, so the origin's no-store or private, or a Set-Cookie, still prevents storage; that veto matters because decoding and dot-segment differences can steer a private route into a cached directory. On a CDN, do not let an edge TTL override origin headers on application routes, and enable protections that compare the URL's extension with the response Content-Type, such as Cloudflare's Cache Deception Armor.

Fixes that do not hold

  • Setting no-store at the origin while the edge overrides it: "cache everything" rules and ignored headers discard the origin's decision.
  • Blocking .css after private paths: ;, encoded slashes, other extensions and dot segments produce the same confusion.
  • Relying on Vary: Cookie: support for Vary differs between caches, and some ignore it.
  • Short TTLs: an attacker can fetch the stored copy within seconds.
  • Validating Host but honoring X-Forwarded-Host: the unvalidated header still reaches the URL builder.

A developer review checklist

  1. List every cache between client and origin, including CDN rules, reverse proxies and framework output caches.
  2. Default dynamic responses to Cache-Control: no-store, private and opt public pages in.
  3. Confirm no edge rule caches by extension or overrides origin headers on application routes.
  4. Request private routes with .css, /x.css and ;x.css appended; each should return 404, not the page.
  5. Strip X-Forwarded-Host, X-Forwarded-Server, X-Host, X-Host-Override and Forwarded at the edge, or accept them only from known proxies.
  6. Build absolute URLs from a configured canonical origin.
  7. Test with a unique cache-buster and marker, and accept only a cache-hit response as proof.

References

Module: Web Cache Deception & Poisoning. How it probes: Deception runs on authenticated scans: it loads each dynamic page signed in and anonymously to find a marker only the signed-in copy contains, then requests the page with .css, /ssecpoc.css, ;ssecpoc.css and .js appended behind a unique ssec_cb cache-buster. Poisoning runs with header attacks enabled, signed in or not: it sends a unique hostname under the reserved .invalid domain through X-Forwarded-Host, X-Forwarded-Server, X-Host, X-Host-Override and a Forwarded host directive on a cache-busted URL. How it confirms: Deception needs the signed-in request to the confused URL to return the private marker and a following anonymous request to return it too, together with a cache-hit signal: HIT in CF-Cache-Status, X-Cache, Cache-Status or a similar header, a positive Age, or an X-Varnish header carrying two request IDs. Poisoning needs the marker reflected in the body or a header, then present again with a cache-hit signal on a clean request that never sent the header; a lone reflection or Cache-Control: public never produces a finding, and the confirming response stays with the finding as evidence. A finding moves from Possible to Confirmed only after this check. Reported as CWE-524, CWE-349 · SARIF 2.1.0.
How SelfSec proves this one
Module
Web Cache Deception & Poisoning
How it probes
Deception runs on authenticated scans: it loads each dynamic page signed in and anonymously to find a marker only the signed-in copy contains, then requests the page with .css, /ssecpoc.css, ;ssecpoc.css and .js appended behind a unique ssec_cb cache-buster. Poisoning runs with header attacks enabled, signed in or not: it sends a unique hostname under the reserved .invalid domain through X-Forwarded-Host, X-Forwarded-Server, X-Host, X-Host-Override and a Forwarded host directive on a cache-busted URL.
How it confirms
Deception needs the signed-in request to the confused URL to return the private marker and a following anonymous request to return it too, together with a cache-hit signal: HIT in CF-Cache-Status, X-Cache, Cache-Status or a similar header, a positive Age, or an X-Varnish header carrying two request IDs. Poisoning needs the marker reflected in the body or a header, then present again with a cache-hit signal on a clean request that never sent the header; a lone reflection or Cache-Control: public never produces a finding, and the confirming response stays with the finding as evidence.
Reported as CWE-524, CWE-349 · SARIF 2.1.0

A reflected response alone is not enough to promote a Web Cache Deception finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find Web Cache Deception in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list