Web Cache Poisoning: One Request, Thousands of Infected Users.
The previous articles covered attacks that require the victim to do something — click a link, open an email. Web Cache Poisoning works differently. The attacker sends one HTTP request and goes for coffee. For the next few hours — or days — every visitor to the site gets the poisoned page from the cache. Automatically, with no interaction required.
In 2018, researcher James Kettle from PortSwigger demonstrated at Black Hat USA how he took over the RedHat.com homepage with a single request. “We just got full control over the home page of RedHat.com, and it wasn’t very difficult,” he said.
HOW CDN CACHING WORKS
A CDN sits between the user and the server. When the first request arrives, the CDN goes to the origin server, gets the response, and stores it in the cache. All subsequent requests to the same URL get the cached response without hitting the server. This speeds up the site and reduces server load.
To decide which cached response to return, the CDN uses a cache key — a set of request parameters that identify the page. The standard key is: request method + URL + Host header.
GET /index.html HTTP/1.1
Host: target.com
# Cache key: GET | target.com | /index.html
Everything that is not part of the cache key is called an unkeyed input. The CDN ignores it when looking up the cache, but still passes it to the origin server. The server may use this data to build the response. And this is where the problem begins.
UNKEYED HEADERS — THE CACHE’S BLIND SPOT
There are headers that the CDN passes to the server but does not include in the cache key. The most common ones:
X-Forwarded-Host — an alternative Host for servers behind a proxyX-Host — similar to X-Forwarded-HostX-Forwarded-Scheme — the protocol (http or https)X-Forwarded-Port — the port
Many frameworks and applications use these headers to build absolute URLs in responses — in links, scripts, and redirect addresses. This is convenient when working behind a reverse proxy.
But the CDN does not include them in the cache key. That means two requests with different X-Forwarded-Host values will get the same cached response — whichever one arrived first.
ANATOMY OF THE ATTACK, STEP BY STEP
Step 1. The attacker sends a request to the homepage with a manipulated X-Forwarded-Host header:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
Step 2. The CDN receives the request, looks up its cache by the key GET | target.com | / — the cache is empty or stale. The request goes to the origin server.
Step 3. The server receives both headers. It uses X-Forwarded-Host to build absolute URLs in the response. Instead of https://target.com/static/app.js, the HTML now contains:
<script src="https://evil.com/static/app.js"></script>
Step 4. The CDN receives the response with the poisoned script and caches it under the key GET | target.com | /. No warnings — the response looks like any other.
Step 5. The next thousands of users open the homepage and receive from the cache the HTML with the evil.com/static/app.js script. Their browsers load and execute it. The attacker left long ago.
REAL CASES
In August 2018, James Kettle presented “Practical Web Cache Poisoning” at Black Hat USA. He demonstrated attacks on real sites:
RedHat.com — the X-Forwarded-Host header was used to generate Open Graph tag URLs in the HTML. Kettle replaced it with his own domain, the CDN cached the response. The RedHat.com homepage began loading resources from the attacker’s server for all visitors.
Mozilla Firefox Shield — the mechanism for delivering Firefox updates and extensions. Kettle poisoned the cache of the response that Firefox receives on startup. The attack allowed him to influence browser behavior for all users who received the poisoned cached response.
Cloudflare ignores the Vary header by default — this is documented in its official cache documentation: “Cloudflare does not consider vary values in caching decisions.” Two exceptions: Vary: Accept-Encoding is supported on all plans; Vary for images is available only on paid Pro plans and above. Neither helps against cache poisoning via X-Forwarded-Host.
HOW TO TEST YOUR SITE
Step 1. Find unkeyed headers — those that affect the response but are not in the cache key. Use a cache buster to avoid accidentally caching anything. A cache buster is a unique parameter in the URL (?cb=test123) that makes the request unique and forces the CDN to go to the origin rather than serve cached content. Without it, the test request could end up in the cache and poison real users:
# Add a unique parameter to avoid hitting the real cache
curl -s "https://target.com/?cb=test123" \
-H "X-Forwarded-Host: canary.example.com" | grep "canary"
If canary.example.com appears in the response — the server is using that header in the response. That is an unkeyed input.
Step 2. Check whether the response is cached — look at the X-Cache header:
curl -sI "https://target.com/" | grep -i "x-cache\|cf-cache\|age"
X-Cache: HIT means the response came from cache. X-Cache: MISS — from the server. Age: 120 — the response has been in cache for 120 seconds.
If the header is used in the response AND the response is cached — the site is potentially vulnerable.
PROTECTION
Do not use unkeyed headers to build URLs
The most reliable protection is to not trust X-Forwarded-Host, X-Host, and similar headers when generating links. The site’s base URL must be set explicitly in config:
# Bad — taking host from header
base_url = request.headers.get('X-Forwarded-Host', request.host)
# Good — taking from config
base_url = settings.BASE_URL # https://mysite.com
Strip dangerous headers in nginx before they reach the application
If the application should not see these headers — remove them at the nginx level:
server {
listen 80;
server_name mysite.com;
# Replace potentially dangerous headers with the real host value
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Host $host;
location / {
proxy_pass http://backend;
}
}
Here we do not simply delete the header — we explicitly set its value to $host (the real host from nginx). The application will receive the correct value regardless of what the client sent.
Add unkeyed headers to the cache key via Vary
The Vary response header tells the CDN which request headers should be part of the cache key. Adding X-Forwarded-Host there causes the CDN to cache separate responses for each value of that header:
Vary: X-Forwarded-Host
Important limitation: not all CDNs respect Vary. Cloudflare ignores it by default. So Vary is an additional measure, not the primary one.
Configure caching rules in Cloudflare
Cloudflare Cache Rules let you configure the cache key. Basic options (cache by device type, query string sorting) are available on all plans. Adding specific HTTP headers to the cache key is an Enterprise plan feature. For plans below Enterprise, the same can be done via Cloudflare Workers (Cache API) without an Enterprise subscription.
Dashboard → Rules → Cache Rules → Create Rule → Cache key → Add setting.
TYPICAL MISTAKES
Thinking that HTTPS protects against cache poisoning. It does not — the attack works at the HTTP header level, TLS is irrelevant here.
Checking only the homepage. The attack works on any cacheable page — static files, API responses, category pages. Kettle found vulnerabilities specifically on homepages because they are cached most aggressively.
Relying only on the Vary header. Cloudflare and some other CDNs ignore it. The primary protection is at the application and nginx level.
Not stripping X-headers from API endpoints, assuming APIs are not cached. If the CDN is configured aggressively or a developer added Cache-Control: public — it gets cached.
CONCLUSION
Web Cache Poisoning scales the Host header attack to a level that requires no victim interaction. One request, a few seconds — and thousands of users receive the poisoned page until the cache expires.
You should be concerned if you have a CDN and your application uses X-Forwarded-Host or similar headers to build URLs. Testing is simple — curl with a marker parameter and grep on the result.
Protection is built not in the CDN, but in the application and nginx: an explicit BASE_URL in config, stripping dangerous headers at the reverse proxy, and never trusting what the client sends in X-headers.
