Nightjar

A request header made Next.js cache private per-user responses

Identifier
CVE-2024-46982
Software
Next.js
Affected
Next.js >= 13.5.1, < 13.5.7 and >= 14.0.0, < 14.2.9
Fixed in
13.5.7 and 14.2.10
Reported by
Allam Rachid (zhero_) and Henry Chen
Disclosed
17 September 2024

Add one header to a request and the response changes its mind about who it belongs to:

http
GET /dashboard HTTP/1.1
Host: example.com
x-now-route-matches: 1

Without the header, that route comes back with Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. That is Next.js telling every cache between it and the browser to keep its hands off. With the header, the same route comes back with s-maxage=1, stale-while-revalidate. That is Next.js telling shared caches the response is fine to store and serve to other people.

This is CVE-2024-46982 (GHSA-gp8f-8m3g-qvj9), and it was found by Rachid Allam, who writes as zhero. His write-up is called "Next.js, cache, and chains: the stale elixir." The GitHub advisory credits him as Allam Rachid (zhero_) along with Henry Chen. Everything below about which header does what comes from his article. The advisory names no headers at all.

the same route, two answers header absent SSG branch not taken GET /dashboard an ordinary request isSSG = false Cache-Control private, no-cache, no-store, max-age=0, must-revalidate header present one OR arm matches GET /dashboard x-now-route-matches: 1 isSSG = true Cache-Control s-maxage=1, stale-while-revalidate
A request header decides how the route is classified, and the classification picks the Cache-Control string.

getStaticProps and getServerSideProps#

Next.js pages get their data from one of two exported functions. The bug is about the difference between them.

getStaticProps runs at build time. Here is the docs line the write-up quotes.

If you export a function called getStaticProps (Static Site Generation) from a page, Next.js will pre-render this page at build time using the props returned by getStaticProps.

The data can't depend on the request, because when it runs there is no request. That output is the same for everybody, forever, until the next build. Cache it as hard as you like.

getServerSideProps runs per request, and it gets handed the request:

js
export async function getServerSideProps(context: GetServerSidePropsContext) {
  const userAgent = context.req.headers['user-agent'];
  return {
    props: {
      userAgent,
    },
  };
}

Cookies, headers, session state, whichever tenant this user belongs to. This output is personal by construction. Next.js knows it, which is why the two get such different Cache-Control headers. Allam lists the defaults:

  • SSR: Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
  • SSG: Cache-Control: s-maxage=31536000, stale-while-revalidate

One year, versus don't you dare.

So somewhere in the server there is a boolean called isSSG, and that boolean decides which of those two strings gets attached to the response. Allam went looking at the conditional in server/base-server.ts that sets it. He found three conditions joined by ORs. One of them was a bare presence check on a request header:

js
req.headers['x-now-route-matches']

That header is internal plumbing. His own reaction to trying it from outside is the honest one:

While the likelihood of it being stripped from an external request is high, the test is surprisingly positive.

s-maxage=1 and stale-while-revalidate#

s-maxage=1 looks like nothing. It isn't the year a genuine SSG page gets, either. The misclassified route comes back with one second. That is the value both the article and the advisory quote for the poisoned response, and neither of them explains the difference. So a shared cache may reuse the poisoned copy for one second, and then it's stale.

The reason that's still a working attack is the directive sitting next to it. From RFC 5861, quoted in the write-up:

The stale-while-revalidate HTTP Cache-Control extension allows a cache to immediately return a stale response while it revalidates it in the background.

So the cache keeps serving the poisoned copy while it goes and asks for a fresh one. The attacker's script is sending a fresh poisoned request every second anyway. Allam's summary:

whatever its duration, a simple script automating the sending of poisoned requests at small intervals is sufficient to 'stabilize' its poisoning.

why a one-second lifetime is enough attacker: one poisoned request per second the cache keeps serving the poisoned copy 0s 1s 2s 3s visitors: s-maxage expires at 1s, stale-while-revalidate serves the stale copy anyway
The entry goes stale after one second, and both stale-while-revalidate and the attacker's next request keep it in service.

One thing to underline if you're assessing this in your own stack. The cache doing the storing does not have to be a CDN. His note is explicit about which cache this is: "the cache affected in this section, as well as in subsequent sections, is the framework's internal caching mechanism". His conclusion from that: "the exploits presented below do not depend on the presence of an external caching layer". You cannot configure your way out of this at the edge. The edge isn't where it happens.

Escalating to stored XSS#

Poisoning a page with the same page is not very interesting. So the write-up pairs the header with a second piece of internal plumbing: the URL parameter __nextDataReq.

When a Next.js page fetches its own data it uses routes shaped like /_next/data/{buildID}/targeted-page.json, and gets back a JSON object called pageProps. __nextDataReq asks for that JSON form directly, on the ordinary page URL. Send both together, and what gets stored in the cache for /dashboard is the raw pageProps JSON. Everyone who then loads /dashboard in a browser gets JSON where the page should be. That's a denial of service, and it's the boring outcome.

Here's the good part. When you request the poisoned page normally, without the parameter, the Content-Type that comes back is text/html. Allam's description of noticing this is my favourite line in the article:

Looking at the poisoned response (/poc) in my proxy gave me a good shot of dopamine.

HTML plus attacker-controlled bytes inside it plus a cache means stored XSS, and getServerSideProps exists precisely to copy request data into the page. His proof of concept puts the payload in the header a site is most likely to be echoing.

http
GET /poc?__nextDataReq=1 HTTP/1.1
Host: localhost:3000
User-Agent: CP TO SXSS ON NEXT.JS : <img src=x onerror=alert('Palestine')>
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Connection: keep-alive
x-now-route-matches: 1
what ends up in the cache entry for /poc GET /poc?__nextDataReq=1 x-now-route-matches: 1 stores cache entry, key /poc body: the pageProps JSON Content-Type: text/html Cache-Control: s-maxage=1, stale-while-revalidate GET /poc an ordinary browser hit the browser renders it as HTML the User-Agent payload runs the victim never sends either the parameter or the header
The attacker's request decides what sits under the cache key, and the next visitor's browser executes it.

From bug bounty work he lists what he actually found reflected in real applications: locale cookies, session identifiers, user-agent, CSRF headers, theme preferences, and once, a Host header.

There's a second route to the same place. The data-fetch URL is a data request by definition, so adding x-now-route-matches to /_next/data/{buildID}/page.json poisons the page endpoint without touching __nextDataReq at all. The buildID is not a secret. Next.js returns it inside the script tag with the __NEXT_DATA__ id. He mentions this because a large ecommerce platform he'd reported to had "fixed" the issue with a change aimed at the parameter.

One clarification from his own advisory, since it's easy to get wrong: "The part concerning __nextDataReq is not affected by the CVE (and is therefore not fixed)." The CVE is the header. The parameter is documented internal behaviour that happens to be reachable.

Affected versions#

The advisory is specific. All three conditions have to hold: Next.js between 13.5.1 and 14.2.9, the pages router, and non-dynamic server-side rendered routes. That means pages/dashboard.tsx rather than pages/blog/[slug].tsx. App-router-only deployments are unaffected. In the advisory's words, "Deployments on Vercel are not affected." Patched in 13.5.7 and 14.2.10. GitHub scores it High, CVSS 8.7.

The write-up ends with a warning aimed at other bug hunters. It says more about this class of bug than the CVSS vector does. With ordinary cache poisoning you test using a cache buster, a junk parameter that gives you your own private cache entry to experiment in. Here the cache being poisoned is first and foremost the framework's. So a cache buster is "very rarely possible", and a test request lands on the real page real users are loading.

His advice is unglamorous. Pick an unimportant endpoint, or ask the program for permission before you carry on. He offers one safer trick, and it applies to the earlier vector: the sites that put an ordinary cache in front and leave URL parameters out of the cache key. Check whether Accept-Encoding is part of the cache key. If it is, send the malicious request without that header. Browsers always send it, so a real user lands on a different key.

The plainer version of the same advice: "blowing up the main page of a fortune 500 with a popup alert in the middle will potentially shorten your career as a bug hunter."

Sources

  1. 1Next.js, cache, and chains: the stale elixirzhero-web-sec.github.io
  2. 2GHSA-gp8f-8m3g-qvj9: Next.js cache poisoning (CVE-2024-46982)github.com