I'm always excited to connect with professionals, collaborate on cybersecurity projects, or share insights.

Social Links

Bug Bounty

Cache Poisoning: The Cache You Can't Purge

Cache Poisoning: The Cache You Can't Purge

Cache poisoning tests usually die the same way. You fuzz the unkeyed headers. You watch for a vendor cache header and a climbing Age. You append a buster so nothing you send lands on a real user. The response comes back private, no-cache, no-store, must-revalidate, and you close the tab.

That response was honest. It just answered for the wrong cache.

Every cache poisoning writeup you have read aims at the edge, because the edge is the only cache that talks back. Behind it, a modern framework runs a response cache of its own. It stores rendered pages on disk before anything reaches your CDN. Nobody switched it on. It shipped with the install. And it does not send X-Cache, it does not send Age, and nothing in a normal response tells you it is there.

That is the cache this article is about.

TL;DR

  • A framework response cache sits behind your CDN. It stores rendered routes on disk and advertises itself in no standard response header.
  • The two caches disagree about what identifies a response. The CDN keys on the full URL. The framework keys on the pathname. Your cache buster never reaches the second key.
  • In Next.js, one unauthenticated request header was enough to pull a no-store route into that cache and hand its response to the next requester. That specific bug is CVE-2024-46982, fixed in 13.5.7 and 14.2.10 in September 2024.
  • The patch added a header scrubber one layer up. The code that trusts the header is still there, which makes it a filter you can test rather than a trust boundary that was removed.
  • On current Next.js, an application that mirrors request headers onto responses plus a cache that ignores Vary produces zero click stored XSS, with no framework vulnerability involved at any point.
  • Cache-Control and Content-Type are not facts about a response. They are labels on it, and on the wrong app they are writable from outside.

Three Caches Behind One Response

A single response can pass through three caches before it reaches a browser, and you have probably only ever attacked one of them.

The CDN is the visible one. You fingerprint it in seconds from a vendor header, an Age value, or an X-Cache line. Unkeyed input research, cache key normalization, path confusion, all of it lives here.

The framework response cache sits behind it. It stores the rendered route on the server, on disk, before the edge ever sees the bytes. In Next.js this is the Full Route Cache, and the object that manages it is ResponseCache.

The data cache sits behind that, holding the fetch results the render was built from. It keys on a hash of the whole request, headers included, which makes it a much harder target. Park it.

That leaves two caches that matter behind the edge, and the interesting one is the middle layer.

01-cache-layers

The Two Keys Do Not Match

The CDN keys on the full URL. Every distinct query string is a distinct entry. That is the model in your head and the model your cache buster depends on.

The framework cache keys on the pathname.

In Next.js the key derivation, historically, was this shape:

`${locale}${resolvedUrlPathname}${query.amp ? '.amp' : ''}`

Read what is missing. No query string. No request header. No cookie. A single query parameter has ever participated in that key in the framework's history, and it entered as a literal .amp filename suffix.

This is not sloppiness. That cache was designed for routes that render the same way on every request, so the path is enough to name them. Nothing enforces that assumption. The danger appears when a route that reads per request input ends up stored in there anyway.

Two correct answers now coexist. Ask the edge and those URLs are separate objects. Ask the framework and they are one object wearing two names. Neither layer is malfunctioning. Only one of them can hear your cache buster.

One Header Switches the Inner Cache On

An ordinary server rendered route in Next.js Pages Router announces itself as uncacheable in the strongest terms HTTP allows:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

Five directives saying the same thing, and no framework cache header anywhere in the response, because that route is not in the cache.

Adding one request header changes that:

x-now-route-matches: 1

The response comes back transformed:

Cache-Control: s-maxage=1, stale-while-revalidate
x-nextjs-cache: MISS

The caching policy inverted, and a cache layer announced itself that was not in the previous response. MISS is not a failure. MISS means an entry was just created and something will be looking for it next time.

The header exists so a hosting platform can signal route matching to the framework. The framework treats its mere presence as a truthiness check. Any non empty value works. It is not authenticated, and on a vulnerable version nothing between the internet and that code strips it.

Send the same request again from a different client and the response is served from the entry the first request created. Same body, same reflected values, same render timestamp down to the millisecond. That page was not rendered for the second requester. It was rendered for somebody else and handed over.

This specific bug is CVE-2024-46982, fixed in Next.js 13.5.7 and 14.2.10 in September 2024.

Scope it correctly, because the instinct here is backwards. It affects non dynamic server rendered routes in the Pages Router. A [slug] route is not the target. And it does not generalise to the App Router, where reading searchParams opts a route into dynamic rendering, which keeps it out of the Full Route Cache entirely.

The Cache Buster That Buys You Nothing

Here is the part that should change how you test.

Take a route that genuinely varies by query string. Two different parameter values render two different pages, and you can prove that in two requests with no special headers. Now pull that route into the framework cache with the header above, using one parameter value. Then request it again with a completely different parameter value.

You get the first response back.

The route read your query string. The cache filed the result under the path. The path did not change, so the parameter you appended to stay out of everyone's way created nothing.

That is the cache buster habit failing silently. Not on every route on the internet, and not as a general property of the framework. On a route that got dragged into this cache without ever being designed for it, which is exactly what that one header does.

The consequence cuts both ways. You are reading somebody else's copy. And if your request had been the one that stored the entry, you would have been writing into theirs.

Where the Poisoned Copy Actually Lives

A cache in memory dies with the process. This one does not.

Next.js writes the cached entry into its own build output, on disk, beside the compiled page. Restarting the application does not clear it. Clearing the framework's cache directory does not clear it either, because that is not where it lives. The only thing that removes it is discarding the build output and rebuilding.

Think about what that does to the people running the site. They purge the CDN and the page is still wrong. They bounce the app and it is still wrong. Their normal cache clearing procedure does not reach the file.

A finding that survives the defender's first three instincts is not a moderate. That sentence belongs in your report.

Be straight about the limit, though. Reading that stored copy back still requires the same header, so on its own you are the only one seeing it. What makes it matter in production is the line that changed in the response: that route now advertises s-maxage to every shared cache in front of it. You did not poison the visitor directly. You handed a private page to whatever cache sits in front and let it do the distributing.

The Patch That Moved the Door

The bug got patched. The trust did not.

Read base-server.ts in Next.js today and the code still reads x-now-route-matches and still shapes its handling of the route from that header. What shipped in 14.2.10 was a scrubber: a separate layer, in a different file, stripping the header before it reaches the code that believes it.

That difference has practical value. Removing the trust would have ended the class outright. Filtering the input instead keeps the class alive and hides it behind an allowlist, and allowlists are enumerable by definition. Find any route into that code the filter does not sit on, and the original behavior returns untouched, because nothing underneath ever stopped believing the header.

Look at how it was announced, too. The release notes carry that security fix as a single line:

Remove invalid fallback revalidate value

No security label. No CVE reference. Nothing that reads like cache poisoning. Anyone watching Next.js releases for security relevant changes in September 2024 scrolled straight past it.

The maintainers clearly treat this as a category rather than a one off. An AGENTS.md in the repository instructs reviewers that any non standard header read "may be forgeable by an external attacker if it is not in the INTERNAL_HEADERS filter list." That is the failure mode written into their own review process as a permanent checklist item.

A patch can close a door without changing anything behind it. The door closed. The idea walked outside, to the cache in front, which nobody patched at all.

Two Ingredients, Neither of Them a Vulnerability

What follows works on current stable Next.js with the App Router. Nothing in it is patched, and nothing in it is a framework vulnerability. The chain comes from research published in June 2026 by Rachid Allam, who writes as zhero_websecurity, with inzo. He reports a five figure bounty for it.

It needs two conditions, and neither is a bug by itself.

One: the application mirrors inbound request headers onto its responses. Somebody wanted trace and correlation IDs to survive the hop so support could match a ticket to a log line, wrote a passthrough, and never bounded which headers it passes. That is an application level mistake, not a framework one, and saying so plainly is what keeps your report alive.

Two: the cache in front does not consult Vary. Next.js appends a Vary header to App Router responses naming the headers that change the content:

Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding

Note the tokens are lowercase, and Accept-Encoding rides along because it is appended separately rather than set. The framework is asking, in writing, for the cache to vary on those. A cache key built from method plus URL never reads any of it.

Vary is the mechanism HTTP gives you for exactly this problem, and it is the mechanism caches implement worst.

The Payload Nobody Escaped

Sending Rsc: 1 on an App Router route changes what comes back. Instead of a rendered HTML document you get the React Server Component flight payload, served as:

Content-Type: text/x-component

Raw component data, in a content type no browser renders. Harmless.

Now look at what is inside it. The route's searchParams are serialized into that payload after the __PAGE__ marker, and your input arrives there unescaped. Send a value containing angle brackets and they come back as angle brackets.

Request the same URL without the Rsc header and the same value comes back HTML escaped in the rendered page. Same application, same input, two encodings.

First Writer Wins

The reflection is what turns that into something. Sending Content-Type: text/html as a request header, on an app that mirrors headers, means the response carries that content type before the framework gets to set its own.

One line in the framework explains why that sticks. In send-payload.js, around line 70:

if (!res.getHeader('Content-Type') && result.contentType) {
  res.setHeader('Content-Type', result.contentType)
}

The framework sets the type only if nothing set it already. First writer wins, and the reflection wrote first.

This is not a parser bug and not a bypass. It is a defensive looking check doing exactly what it says. The result is React internals, carrying your unescaped input, wearing an HTML label.

A Safe Type Is a Promise

Nobody forgot to escape the flight payload. Skipping it was a reasonable decision at the time it was made.

Ask where that payload was ever meant to end up. It ships as text/x-component, a type browsers hand to a JavaScript runtime and never to an HTML parser. No DOM to inject into. No attribute to break out of. No script context to reach. Escaping it for markup would mean guarding a doorway that does not exist, and no reviewer would ask anyone to pay for that.

So the content type quietly took over the job an encoder would normally do. Nobody documented the arrangement, because there was nothing to document.

Controls like that fail without a sound. Take one away and the application still works, the tests still pass, and the missing protection leaves no visible hole. What breaks the arrangement is a change of label, not a change of bytes. The payload is byte identical before and after. Only the promise wrapped around it moved.

The lesson travels a long way past one framework. Look for anywhere encoding got skipped because the output format made it pointless. JSON that never meets an escaper, on the grounds that JSON is not markup. Plain text endpoints. SVG the developer assumed would only ever load through an image tag. File downloads leaning on a disposition header to stay inert.

So when you read a response, ask two questions. What in here was never escaped, and what label is keeping it safe. If you can reach the label, the escaping never mattered.

Turning the Cache On Yourself

Raw injection inside a retypeable response is still nothing if the response is never stored. Most of an application returns private, no-store, which puts most of it out of reach.

Unless you can set the storage policy yourself.

The same first writer wins pattern sits one block above the content type check in the same file, around line 60, governing cacheability:

if (cacheControl && !res.getHeader('Cache-Control')) {

If the header is already present, the framework leaves it alone. And the reflection sets it. Sending Cache-Control: public, max-age=300 as a request header produces a response that a shared cache will store.

You did not find a caching bug. You told it to cache. Two instances of the same shape in one handler makes it a pattern rather than an accident.

Worth being precise about credit here: the published research needed the target's CDN to already be caching the route. Reflecting Cache-Control removes that precondition, and that extension is not part of the original writeup.

Refresh Instead of Location

Storage is not reach. Somebody still has to send that exact request with that exact header, and no real visitor will.

So you poison a second entry. The payload URL holds the executable response. A clean URL, the one a normal person actually visits, holds a single header that pushes the browser into it:

Refresh: 0; /search?q=<payload>

Location does not work here. Browsers ignore it on a 200, and the response has to stay a 200 because a redirect status changes how it gets stored. Refresh fires on any status code.

That primitive outlives this bug. Refresh never made it into the HTTP specification, browsers kept it anyway, and almost everyone thinks of it as a meta tag rather than a response header. Which is exactly why nothing checks for it.

Two entries, then. The victim only ever requests the clean one, with no query string, no headers, and nothing appended. The browser follows the refresh into the poisoned entry and the payload executes.

02-zero-click-chain

Fingerprinting It on a Real Target

Reflection comes first, because nothing else matters without it. Pick a header name that plausibly belongs to tracing infrastructure and give it a value you will recognise anywhere. X-Request-Id, X-Correlation-Id, X-Trace-Id and X-Forwarded-Host all earn a place on that list, because anyone wiring distributed tracing through an application tends to echo the identifier back so support can match it against a log line. Debug middleware nobody switched off behaves the same way. Unglamorous infrastructure is where this class lives.

Attach it to a request that already succeeds. Errors are not the signal. Your own value reappearing is. Search the entire response, header block and body together, since a passthrough holds no opinion about where its output ends up.

Route selection comes next, and getting it wrong burns hours. Pages generated ahead of time were rendered before your request existed, so no header you send can reach them. What you want is a route that executes per request. Next.js labels this for you. x-nextjs-prerender on the response means skip it. Its absence means the render runs live, with your input inside the loop.

Then find out whether the layer in front respects content negotiation at all. Ask for one URL twice, once carrying Rsc: 1 and once plain. Any cross contamination between those two answers, a flight payload landing on an ordinary browser request or a full document landing on the RSC one, means both were filed under the same key and Vary was never read. That is the gap.

Aim at low value pages first. Documentation, marketing, help centers, status pages. Security attention concentrates on authenticated flows, while pages like these sit unguarded and still serve traffic to strangers.

Writing the Report So It Survives Triage

This class dies in triage more than anywhere else. A triager sees a header you sent, decides it only affects you, and closes it as self inflicted.

The only answer is evidence that somebody else received it. Store the entry from one client. Retrieve it from a second, stripped of every custom header, ideally from another address. Capture both halves of that exchange and put the cache hit in the report beside them.

Then state the cache key you believe is in play and why you believe it. Give both requests in order, with the interval between them, and how long the entry survived. If you can show the poisoned response outliving a CDN purge or an application restart, say so explicitly, because that is the difference between a moderate and something the team has to schedule.

Prove delivery, not injection. That is the hard part and it is not optional.

The Seam Nobody Owns

Nothing in this chain required an undisclosed bug. Every component behaved the way its own documentation describes.

What it required was looking one layer deeper than habit allows. The edge answers when you knock, which is why it absorbs all the attention. The cache sitting behind it never announces itself in any response you have been reading, and that silence is the entire opportunity.

It also required reading response headers as input rather than output. Cache-Control, Content-Type and Vary describe how a response should be handled, and against an application that echoes what you send, you become the one doing the describing. A payload kept harmless by its declared type is only ever as safe as your inability to change that declaration.

According to the research, the framework's own answer is that this cannot be solved from their side, precisely because caches are so inconsistent about honouring Vary. So the category is understood, written into their internal review process, and still open. Closing it would need cooperation from a component they do not ship and cannot control.

No team owns that seam. That is exactly why it is still open.

Further Reading

17 min read
Sep 12, 2026
By Amr Elsagaei
Share

Leave a comment

Your email address will not be published. Required fields are marked *

Related posts

Aug 17, 2026 • 17 min read
Client Side 03: DOM Clobbering
Jul 09, 2026 • 17 min read
Hunting WebSockets: Bugs Behind 101 Switching Protocols
Jun 05, 2026 • 17 min read
Auth for Hackers
Your experience on this site will be improved by allowing cookies. Cookie Policy