The cache said yes: web cache deception, cache poisoning, and CVE-2024-46982 in Next.js
Web cache deception stores a victim's private response for anyone to fetch; cache poisoning stores an attacker's response for everyone. Both come from the cache and the origin disagreeing about a URL. CVE-2024-46982 in Next.js is the current, well-documented case.
By Ahmed PingerPublished
9 min read1 view
A cache is a shortcut built on an assumption: that two requests for the same key deserve the same response. The key is usually the URL path, sometimes with a few headers. When the cache and the origin server disagree about what a URL means, or about whether a response was ever meant to be shared, the shortcut turns into a security hole. Two attacks live in that gap, and they are mirror images of each other.
The bug in one distinction
Web cache deception tricks the cache into storing a response that was private to one user, so the attacker can fetch that stored copy afterwards. The victim requests their own account page; the cache is fooled into filing that page under a shareable key; the attacker requests the same key and receives the victim's data.
Web cache poisoning is the reverse. The attacker sends a request that produces a harmful response, the cache stores it under a key that real users will request, and every subsequent user is served the attacker's version.
Deception steals one user's private response out of the cache. Poisoning plants one attacker's response into the cache for the crowd. Both begin the same way: the cache decided to store something it should not have, because it and the origin read the same request differently.
Why it varies
Whether a given URL is cacheable depends on a chain of decisions, and each link can disagree with the next.
- Path confusion. A cache configured to cache anything ending in
.cssor.jssees/account/profile/fake.cssas a static file. A framework that routes on/account/profileand ignores the trailing segment serves the real, private profile. The cache stores the private body under the static-looking key. - Delimiter discrepancy. The cache and the origin split URLs on different characters. A semicolon, an encoded slash, or a newline can end the path for one and not the other, so the two compute different keys for the same bytes.
- Normalisation discrepancy. One side decodes
%2fto a slash, or collapses.., and the other does not. The keyed path and the routed path drift apart. - Unkeyed input. Poisoning specifically exploits inputs that influence the response but are not part of the cache key: a header the cache ignores but the app reflects, for instance. The attacker changes the response without changing the key, and the poisoned copy is served to everyone who requests that key.
In every case the root cause is the same shape as a lot of web bugs: two components parsing the same input with different rules and no shared source of truth.
The real CVE
CVE-2024-46982 is a clean, recent example of the caching decision going wrong inside the framework itself rather than in a misconfigured CDN. NVD published it on 2024-09-17 with a CVSS 3.1 base score of 7.5 (High) and classified it as CWE-639. The GitHub advisory GHSA-gp8f-8m3g-qvj9 from the Next.js maintainers describes it precisely: a crafted HTTP request could coerce Next.js into caching a Pages Router route that was meant not to be cached, and into emitting a Cache-Control: s-maxage=1, stale-while-revalidate header that some upstream CDNs would then cache as well.
The conditions are worth reading, because they define the blast radius exactly. All three had to hold: Next.js between 13.5.1 and 14.2.9, the Pages Router (not the App Router), and a non-dynamic server-side rendered route such as pages/dashboard.tsx rather than pages/blog/[slug].tsx. The maintainers resolved it in 13.5.7 and 14.2.10. Applications hosted on Vercel were not affected, which is itself the lesson: the same code behaved differently depending on the caching layer in front of it, because caching is a property of the whole path, not of one file.
The fix, visible in the patch, removed an internal fallback that let an invalid revalidate value default to 1 instead of forcing the render to return a valid value. In other words the framework stopped guessing a cacheable answer where it previously had one. That is the same move the strongest request-smuggling fixes make: refuse the ambiguous case rather than paper over it.
A lab you can build
No Docker required. A Node runtime and a single caching reverse proxy on one Linux VM reproduce the whole class.
- Create a small Next.js app pinned to a version in the affected range, or, if you would rather stay framework-agnostic, write a tiny Node HTTP server with two routes: one that returns a per-user secret and sets
Cache-Control: private, no-store, and one that returns a static asset. - Put a caching proxy in front of it. Varnish, nginx with its proxy cache, or Squid all install from a package manager. Configure it the way real CDNs often are: cache by URL path, and cache anything that looks like a static extension regardless of the origin's
Cache-Control. - From two different sessions, request the private route through the proxy, once plainly and once with a static-looking suffix or an alternate delimiter, and watch the proxy's
X-Cacheor age headers.
The finding is the moment the private response comes back marked as a cache hit for a request that carried no session. You are demonstrating the disagreement, not harvesting anyone's data. To study poisoning instead, add a header your app reflects into the body but the proxy leaves out of its key, and observe that the reflected value survives into a later request's cached response. Both experiments stop at "the cache stored what it should not have," which is the whole point.
Finding it in source and config
- Read the cache key, then read the router. Write down exactly what the cache keys on (path, which headers, query string) and exactly how the application maps a URL to a handler. Every place those two differ is a candidate. This is a config review as much as a code review.
- Hunt for extension and delimiter rules in CDN config. Rules of the form "cache all
*.css,*.js,*.png" are the classic deception enabler when the origin does path-prefix routing. Semicolon and encoded-slash handling belongs in the same review. - Find responses that vary on unkeyed input. Grep the app for headers read out of the request and reflected into responses (
X-Forwarded-Host, custom headers, language and region headers). Anything that changes the body but is not in the cache key is a poisoning primitive. - Check the framework version against known caching CVEs. For Next.js specifically, confirm you are past 13.5.7 or 14.2.10.
Fixing it
Patch the framework. CVE-2024-46982 is fixed in Next.js 13.5.7 and 14.2.10. Version currency is the baseline.
Make the origin authoritative about cacheability. Set Cache-Control: no-store and private on anything user-specific, and configure the CDN to honour origin cache headers rather than override them with blanket extension rules. The origin knows what is private; the cache should defer to it.
Match the cache key to the routing reality. If the origin ignores trailing path segments, the cache must not key on them as if they were files. If the app varies on a header, that header belongs in the key or the Vary response header, so the cache stops serving one user's variant to another.
Reject ambiguous requests at the edge. Normalise or refuse encoded slashes, stray delimiters and double-encoding before they reach a component that will interpret them differently. Consistent interpretation across the path is the durable fix.
Detecting it
Cache metadata is the evidence, because the bug is a storage decision and every cache records the decisions it makes.
- Cache hits on responses that were marked private. Log the cache status per response (nginx exposes it as
$upstream_cache_status, Cloudflare asCF-Cache-Status, Varnish throughvarnishlog) next to the origin'sCache-Controland whether the response carriedSet-Cookie. A hit on a response that saidprivateorno-store, or that set a cookie, means the cache stored what it was told not to. That is deception in progress, or one request away from it. - Static-looking paths served by dynamic handlers. A request for
/account/profile/x.cssthat the origin answered withtext/htmlis the deception key being planted. Compare the path extension against the response content type at the edge and alert on the mismatch. - A route's hit ratio moving without its traffic. A private route that suddenly has a cache hit ratio, or a burst of requests for one odd key from clients with no session cookie, is the attacker collecting the stored copy.
- Reflected unkeyed input. For poisoning, log
X-Forwarded-Hostand any other header the cache does not key on when it arrives on a request to a cacheable route, and alert when the cached body for one key changes between hits with no deploy in between. - Delimiter and encoding oddities. Requests carrying
%2f, semicolons,%0aor double-encoded sequences on non-static routes are the normalisation discrepancy being probed. The edge normaliser from the fix section should log what it rejects.
Take this away
Web cache deception and cache poisoning are one root cause wearing two masks: the cache and the origin disagreed about a URL, or about whether a response was ever shareable, and the cache stored the wrong thing. CVE-2024-46982 shows it can live inside the framework, in a fallback that assumed a cacheable answer where none was warranted, and shows that the same code was safe or vulnerable depending only on the caching layer in front of it.
So audit the layer, not just the file. Write down what the cache keys on and what the router routes on, and treat every difference between them as a finding until proven harmless. The cache's job is to repeat responses; your job is to make sure it only repeats the ones that were meant to be shared.
Further reading
- NVD: CVE-2024-46982: the Next.js cache poisoning entry, CWE-639, CVSS 7.5
- Next.js advisory GHSA-gp8f-8m3g-qvj9: the maintainers' description, affected conditions and fixed versions
- PortSwigger: Web cache deception: path confusion, delimiter and normalisation discrepancies
- PortSwigger: Web cache poisoning: unkeyed input and how a poisoned entry spreads
- USENIX Security 2022: Web Cache Deception Escalates!: a large-scale study of the attack in the wild
- MDN: the Vary header: keying a cache on the inputs a response actually depends on
Was this useful?
Get the next write-up by email
New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.
Prefer a feed? RSS. How we handle your address: privacy notice.
Comments
Loading comments…