DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Web Cache Deception Against APIs: CDNs Cache What Backends Serve Privately

Web Cache Deception Against APIs: CDNs Cache What Backends Serve Privately

ChatGPT's authentication API served session tokens to anonymous users for weeks. No vulnerability in the API. No injection. The only requirement was a URL ending in .css.

CDNs cache by extension. APIs route by path. When the CDN sees .css and the backend sees /api/user/profile, a private response enters the public cache. WCD is more dangerous against APIs than against web pages. APIs return JWT tokens, CSRF tokens, and complete user records in structured JSON.

The CDN sees .css, the backend sees /api/user/profile

CDNs apply cache rules by file extension. The default rule on Cloudflare, CloudFront, and Fastly: *.css is cached for 1 hour. The extension is the signal, not the Content-Type and not the response body.

The backend reads the URL differently. Express, Django, and Rails route /api/user/profile.css to the same handler as /api/user/profile. The framework ignores the extension because path matching strips it before consulting the defined routes.

The CDN applies the cache rule before the request reaches the origin. It never inspects the response Content-Type before storing. Omer Gil demonstrated this against PayPal at Black Hat USA 2017. The URL /myaccount/home.css returned the complete authenticated account page and was cached publicly for any subsequent visitor.

The CDN makes no distinction between a stylesheet and a JSON API response. A Content-Type: application/json on the response does not cancel the *.css cache rule. The cache key is built from the URL, not from the Content-Type or the response body. This is the detail most API developers miss when building endpoints behind a CDN.

The attacker flow: the authenticated victim visits /api/user/profile.css. The CDN stores the authenticated JSON under that URL. The next visitor makes the same request and receives the private data from cache.

Three modern variants beyond the static extension trick

Path delimiter confusion, URL-encoded traversal, and CDN-origin normalization inconsistencies extend WCD well past appending .css. The ChatGPT account takeover, with a $6,500 bounty, used none of the original technique.

Delimiter confusion (Martin Doyhenard, PortSwigger / Black Hat USA 2024): Spring MVC treats the semicolon as a matrix variable delimiter. The CDN sees /api/profile;.css and caches it as a CSS asset. Spring sees /api/profile and returns the full user data.

Doyhenard documented variants for other frameworks too. The dot in Rails acts as a format delimiter: /profile.json versus /profile, making /profile.json.css routable to the profile handler. The null byte (%00) in OpenLiteSpeed terminates the path before extension processing. The encoded newline (%0a) in Nginx separates the path from the fake extension. Each framework introduces a different delimiter that creates the same CDN-backend gap.

URL-encoded traversal: /share/%2F..%2Fapi/auth/session. Cloudflare applies its wildcard cache rule for /share/*. The origin decodes %2F and serves /api/auth/session. The complete session token is cached publicly. This was the ChatGPT ATO vector discovered by nokline, with a $6,500 bounty.

The normalization gap occurs when the CDN and origin resolve the path differently. The CDN caches under the pre-normalization URL; the origin receives the normalized path and serves the private response. Cloudflare, Fastly, and Google Cloud do not normalize paths before evaluating cache rules. Azure and CloudFront do. The attacker selects the CDN that creates the gap.

CVE-2026-2836 (Pingora, CVSS 8.4): the default cache key used only the URI path, excluding the host header. Responses from different origins were served from the same shared cache. Fixed in Pingora v0.8.0.

What the cache stores: tokens and structured records

WCD on web pages leaks HTML markup. WCD on APIs leaks structured data.

Glassdoor H1 #1343086: the gdToken (CSRF token) was cached via WCD. The attacker fetched the token and forged authenticated requests on the victim's behalf, completing an account takeover chain.

Algolia H1 #1530066: personal account data was cached and returned to an unauthenticated request for the same URL. The victim visited the deception URL while authenticated; $400 bounty.

The type of data varies by endpoint, but the severity is consistent. Profile endpoints return PII: name, email, address. Session endpoints return opaque tokens that remain valid until explicit revocation. Admin endpoints return user lists and internal configuration. The attacker does not need to know in advance what data type is cached.

The Railway CDN incident (March 2026, CloudFront): a configuration update accidentally enabled shared caching for approximately 0.05% of domains. The window was 52 minutes. Authenticated GET responses, including endpoints in the /api/me class, were served from cache to users without session cookies.

The JSON structure means the attacker receives data ready for automation: {"session": "eyJ...", "user": {"email": "...", "roles": ["admin"]}}. Not a page to scrape. A structured object for direct exploitation.

Cache-Control: no-store is necessary but not sufficient

Cache-Control: no-store instructs the CDN not to store the response. Most CDNs respect this header. CDN configuration can override origin headers.

A caching rule that ignores origin headers turns no-store into a suggestion. CVE-2025-61598 (Discourse, versions before 3.6.2): error responses (4xx, 5xx) were missing the Cache-Control: no-store header. Developers rarely instrument error responses with cache control. Proxies stored and served them to subsequent users.

The Railway incident illustrates the second vector: Cache-Control: private was correct in the responses, but a configuration change removed it for 52 minutes. The CDN stored the now-unconstrained responses immediately.

Cloudflare Cache Deception Armor checks whether the response Content-Type matches the URL extension. A application/json response for a *.css URL does not enter the cache. The Armor is effective against the static extension trick. It is not effective against delimiter confusion or normalization gap variants, because those vectors do not rely on extension-to-Content-Type mismatch.

Vary: Authorization includes the Authorization header in the cache key. Requests with different Authorization values receive separate cache entries. Unauthenticated requests, without an Authorization header, never match authenticated cache entries.

Testing in 2 requests

Every authenticated GET endpoint behind a CDN is a candidate. The test requires 2 HTTP requests, a response body comparison, and attention to 3 headers.

Request 1: authenticated. GET /api/me.css (or /api/me;.css, /api/me%23.css). Record the response body and the Cache-Control value. X-Cache: MISS confirms the CDN has not stored yet.

Request 2: unauthenticated (different IP or incognito window), same URL. X-Cache: HIT with the same response body as request 1 is a confirmed finding. The Age: 45 header indicates the cached response is 45 seconds old. It will be served for the remainder of the TTL to any request at that URL.

Extension variants: .css, .js, .png, .jpg, .ico, .woff, .svg. Delimiter variants: ;.css, /.css. URL-encoded: %2F..%2Fapi/me.

The presence of CF-Cache-Status: HIT on Cloudflare or X-Cache: Hit from cloudfront on CloudFront confirms a CDN-level cache hit. An Age header greater than zero on any private endpoint is a finding. Endpoints that return user-specific data should never show Age > 0.

Defense that holds

Set Cache-Control: no-store on every API response that returns user-specific data. Not only on the responses you think need protection.

Add Vary: Authorization and Vary: Cookie so the CDN uses authentication context in the cache key. Unauthenticated requests never match authenticated cache entries.

Configure CDN cache rules by Content-Type: application/json (never cache), not by file extension. The extension is attacker-controlled. The response Content-Type is not.

Audit CDN configuration for rules that override origin Cache-Control headers. Aggressive performance optimization creates WCD exposure.

Normalize URLs at the edge before evaluating cache rules: strip semicolons, decode %23, %3f, and %2f sequences before applying wildcard and extension matching. This step eliminates delimiter confusion and most URL-encoding variants before the request reaches the origin.

The MAGO Intel tool (intel.mago.team) probes API endpoints by appending known extension variants and delimiter patterns. It compares authenticated and unauthenticated responses to identify cache deception windows.

The CDN serves from cache without checking who asked. The API enforces authorization per request. The deception lives in the gap between those two facts.

Top comments (0)