Yeah it’s over for NextJS... 13 NEW vulnerabilities

BBetter Stack
Computing/SoftwareBusiness NewsInternet Technology

Transcript

00:00:00It happened again. This is like my third video on server component CVEs this year and I don't
00:00:05even think I covered all of them. This time it's 13 CVEs, yes 13 of them, across React
00:00:11and Next.js, 6 of which are high severity and include denial of service, middleware
00:00:15bypasses, cross-site scripting and more. Maybe server components were a mistake.
00:00:20So here's the Next.js security release, you know just fixing a few casual issues in here
00:00:28that they've had this month and down at the bottom obviously the resolution is to upgrade
00:00:32all of your Next.js versions and these are the impacted versions. It's worth noting
00:00:36TanStack is not impacted by this which I might be biased but that's another reason to use
00:00:41TanStack for me. Now I won't go through all of these as we'd probably be here for a while
00:00:44and I also haven't found working exploits for all of these but I do want to show you one
00:00:48from each category and we'll start out with a middleware and proxy bypass and the one I've
00:00:52managed to recreate is this pages router one. So we have a middleware proxy bypass in the
00:00:56pages router if you're using i18n and you can see this is a CVE with a 7.5 out of 10
00:01:02severity. This is an example of a vulnerable application so in the Next.js config I've turned
00:01:06on i18n and also set up two locales so English and French then I also have a middleware file
00:01:12which has actually been renamed to proxy in later versions of Next.js to try and avoid
00:01:16the confusion that I'm about to show you but essentially what middleware should allow us
00:01:19to do is modify an incoming request whether that's by redirecting it, rewriting it or adding
00:01:24in some headers or anything like that. In my case I'm using it so we try and visit the
00:01:28/secret page it checks if they have a session cookie so they're logged in and if they're
00:01:32not it should redirect them to the login page so hopefully only all users should be seeing
00:01:37my secret page. At the bottom we also have a matcher so that any middleware that we're
00:01:41applying to that secret page is also matching the locale variants of them because technically
00:01:45now since we have two locales we have three versions of this URL. In the secret page itself
00:01:50I then also have some server-side props so these should be fetched off the server at render time
00:01:54and again since we set up that middleware theoretically only a logged in user should be able to see
00:01:58what these values are which I then later use on the page itself so an email, a flag and
00:02:03also a headline. Again only an authored user should be able to view these. Let's put that
00:02:07to the test then and the first thing I'll try and do is access that secret page and you can
00:02:11see I'm redirected to the login since I'm not logged in which means our middleware is working
00:02:16but what have we turned into master hackers? Well we could do that by first inspecting element
00:02:20absolutely crazy hacker stuff and then in the next data script down here we need to look
00:02:24for our build ID so in my case that's this one here and we can go ahead and copy that
00:02:28then we need to type in a URL which is underscore next slash data slash the build ID that we
00:02:32just copied and then the page we're trying to access dot JSON. Once you've done that you
00:02:37can see we get back those props that should have been behind that middleware and protected
00:02:40which in my case was a flag email headline also something telling you to subscribe for
00:02:44more developer news tutorials tips and tricks. So go ahead and do that I hope I've impressed
00:02:48you with my crazy hacker skills there but why does this actually happen? Well this one is
00:02:52as easy to explain as it was to do since we had our secret page and some server side props
00:02:56in next year server side props are served from a URL that looks like this but our middleware
00:03:00should have been protecting this route. The trouble is since we were using i18n we also
00:03:05had two other URLs which was the English and French variant you can see that server side
00:03:09props also get the English and French variants as well and next year's just had some faulty
00:03:13code which meant if we did have i18n enabled it wasn't protecting the base case so this
00:03:18wasn't included in the matcher but the other two were so English and French versions were
00:03:22protected but not the base case of slash secret. We can see that really quickly if I just change
00:03:26this URL to the English version I do get redirected to the login page. An incredibly simple vulnerability
00:03:31then but I'll be honest with you these middleware bypasses often sound a lot worse than they
00:03:35are they're not great but you shouldn't really be protecting much with just middleware anyways
00:03:40and next.js don't even recommend that you do this. If you did have some sensitive data
00:03:44in those server side props and you had no sort of server auth logic well I feel like part
00:03:48of the issue is on you as well so let's move on to a more damaging one which is denial of
00:03:53service there were three of these but there was only one I could reliably recreate and
00:03:56that was this one here which is denial of service with server components and this impacts next.js
00:04:01as well as anything using the react server DOM package which is pretty much just next.js
00:04:05and the other frameworks that have copied it like vinxt and a few of the other forks. Tanstack
00:04:10start doesn't use this so it's not vulnerable to it. You can see this also has a severity
00:04:14of 7.5 out of 10. With this one all you need is a very simple next.js application and in
00:04:18it you need to be using a server action but again it can be a very simple one. This is
00:04:22that site up and running and you can see when I refresh the page there is pretty much no
00:04:25loading it is nearly instant and to put some actual numbers behind that if I send this request
00:04:29you can see it resolves in 0.02 seconds but if I now go ahead and run my exploit and then
00:04:34send that request again this time it took six seconds and that was from me running the exploit
00:04:39once so imagine what would happen if I chained it. Now to understand this exploit we need
00:04:42to know a little bit about the react flight protocol which is the format that react uses
00:04:46to serialize component trees and data between the server and the client. You've probably
00:04:50actually seen this before so on this page we had a form that had a server action. If I go
00:04:54over to the network tab here and click send you can see that the payload actually gets
00:04:58sent as data that looks like this which kind of looks like gibberish and the same for the
00:05:02response here. If we go ahead and copy this payload I can explain what happens when it's
00:05:05sent to the server. The first step is deserialization and it will start on chunk 0 where we have
00:05:10this $k1. This $k1 is actually just a pointer saying there's going to be some form data here
00:05:16that starts with one underscore so it will take all of the other keys that we sent over
00:05:20as part of that payload it will pass over all of them and look for a string that starts with
00:05:24one underscore and it will know this will be the key and that this is the value. So once
00:05:28it's done that it can say we have name, email, message and it will simply turn this data into
00:05:32this object that we have down here. Nice and simple. The trouble with this approach though
00:05:36is what happens when we scale it. Say I add in another pointer this time looking for $k2
00:05:41this is going to look for all of the keys that start with two underscore. The trouble
00:05:44is now when we're on $k1 it's going to pass over all six of these looking for the ones
00:05:48that start with one underscore and when it goes to $k2 it's going to do the exact same
00:05:52thing but looking for the ones that start with two underscore. So we're now passing over 12
00:05:56keys in total. Now that's not too bad but let's take this to the extreme. If we add in 199,999
00:06:03random keys to the payload that we're sending over and then we change our array on zero here
00:06:07to go $k1 $k2 all the way up to $k1000 that means it's going to have to look for one underscore
00:06:12two underscore three underscore all the way up to a thousand underscore all over our 200,000
00:06:17keys that we have in our payload and that means in total it's going to do 200 million string
00:06:21comparisons. As you can imagine that is going to block the thread for a fair few seconds.
00:06:25This is the commit that I believe fixed the problem that we were having. You can see there
00:06:28is a lot going on in this commit and to be honest it's a little bit complex but try and
00:06:33explain this the best I can. Essentially now they use a cursor based system for the keys
00:06:36so they load in all of these 200,000 keys that we sent in our payload into a list and then
00:06:41we start on zero here where it's looking for the $k1 reference and it starts going down
00:06:45that list with a cursor that cannot go backwards. So it goes down here to $j1 it sees that doesn't
00:06:50match the one underscore that we need so it goes to $j2 that doesn't match one underscore
00:06:54either so it continues all the way down this key list down to $j199,999. Once it's reached
00:07:01here it realizes there is no match for $k1 so it moves on to $k2. Now $k2 starts looking
00:07:06for two underscore but the trouble is since this is a cursor based system and this cursor
00:07:09cannot go backwards it immediately runs out at the end of the list so that is also going
00:07:14to be undefined and that continues all the way up to $k1000. So now this time we've only
00:07:18gone over 200,000 keys. Essentially this fix has taken the number of operations down from
00:07:23$k*n where $k is the number of $k references we have and $n is the number of keys all the
00:07:27way down to $n+k. So in our case we went from 200,000,000 operations down to 201,000 since
00:07:33it does still need to go over all of the keys and also those $k references. I think this
00:07:37tweet from Prime really sums up the situation we're in. Making your own protocol with serialization
00:07:41is incredibly hard so it's not surprising that we're seeing this many issues. In my opinion
00:07:46they need to get Claude Mythos to do a once over on the React and Next.js codebase. Next
00:07:50up we have the highest severity CV of the lot which is server-side request forgery in
00:07:54Next.js applications. You can see this one is ranked 8.6 out of 10 but it is also worth
00:07:59noting that this one didn't impact for cell-hosted deployment so only self-hosted or other providers.
00:08:04This exploit is also super simple to take advantage of. First we need to launch our Next.js server
00:08:09and again this can be a stock Next.js application. You don't have to make any modifications.
00:08:14Next we're also going to want an internal server. So say this server could only be accessed by
00:08:18the Next.js server and not by the outside world. Say this was on our cloud deployment.
00:08:23Then what we're going to do is simply send a very simple curl request where we send a
00:08:26curl request to our Next.js application. So that was on port 3002 and we say we want our
00:08:31request target to be the server that we want access to on that localhost URL. If I now hit
00:08:36enter on this you can see what is returned. It's actually the HTML page for a Python server
00:08:40where it just has that basic directory listing and if I go back to the Python server itself
00:08:45you can see that it had an incoming request that actually came from the Next.js application.
00:08:49To better picture what we just did say in our dotted line here is our deployment of Next.js
00:08:53so we have our Next.js server and then also some internal service whether that's Redis
00:08:57a database or any other service and this one here you don't want to have access to the public
00:09:02so no one can send a curl request to this it will simply fail it is firewalled and locked
00:09:06off and can only be accessed by the Next.js server. What we've done is we've simply sent
00:09:10a curl request to the Next.js server and we've said hey can you send a request on our behalf
00:09:15to the internal service and we've got that information back so we've bypassed that firewall
00:09:19by going through Next.js which does have access to the internal service. The root cause of
00:09:23this one is also pretty simple basically in our curl request we send an upgrade websocket
00:09:28header and when we send these headers what happens in Next.js is we get to this piece
00:09:32of code and this does resolve routes on our URL but the past URL that we get out here is
00:09:37actually the request target that we send in our curl request so this is going to be the
00:09:40target that we're trying to reach on that internal server and not actually the Next.js application.
00:09:45What happens with this past URL is it goes through one check down here to say does the
00:09:49past URL have a protocol but in this case it's yes since we're using HTTP and that is a protocol
00:09:55so it goes ahead and proxies that request for us. The fix for this simply adds in two new
00:09:58guards on our resolve routes function that we have before we're now getting a finished
00:10:02boolean as well as a status code. What this resolve routes function actually does is it
00:10:06takes our URL and processes whether it's a legitimate proxy request based on our Next.js
00:10:11rewrites middleware and things like that if it's not it means finished is going to be set
00:10:15to false so down here we have a check if finished is true we can move on to the next step if
00:10:20it's not it's set to false so this won't run which means we won't run our proxy request
00:10:24and in the case of our curl request that we had previously that is exactly what's going
00:10:27to happen finished will be set to false. Now if somehow finished was set to true our next
00:10:32check is the status code when resolve routes run we actually get a status code back if the
00:10:36request is a HTTP one so it's going to be 200 404 anything like that so if there is a status
00:10:41code that means it's not a valid web socket proxy request so it's simply going to ignore
00:10:45this line and not run it. I've really tried to dive deep into these issues in this video
00:10:49so let me know if you're still watching by commenting something random I don't know like
00:10:53bar or something like that and also subscribe if you appreciate the content. We've got two
00:10:58more categories to get through but these ones should be a bit quicker and I'll start out
00:11:01with cache poisoning where I managed to recreate this moderate issue here and you see this one
00:11:04is cache poisoning in react server component and it's ranked 5.4 out of 10. To recreate
00:11:09this one I have a Next.js application but then I also have a fake CDN to act as if this was
00:11:14in a real deployed environment. This means if I visit my site for the first time on its
00:11:18CDN URL and then click browse products and go back and click it again the first time it
00:11:23should be a cache miss and then next time a cache hit. We can see that happen in the logs
00:11:27here we first had a miss slash products with a query string and then next times it was a
00:11:31hit. What I've done next though is I've cleared the cache to simulate maybe the cache expired
00:11:35or something else and now I can send this curl request. With that back on my application
00:11:39if I click browse products now you can see we get a load of gibberish back so we've successfully
00:11:44done a cache poisoning attack. What I believe is happening here is when we send our curl
00:11:47request with a header of react server components being one this URL is going to return its server
00:11:52component data instead of HTML. Then when this needs to be cached next is going to go through
00:11:58a function which checks if it's server component data or not. If it is server component data
00:12:02it will store it in the cache as so and if it's not it will store it as HTML. This means
00:12:07theoretically when the user tries to go and get the HTML version of the page so by just
00:12:11clicking the button it should never return server component data. The trouble is in our
00:12:16case since we had this query string at the end of our server component curl request it
00:12:20didn't actually meet the requirements for checking whether it's a server component or not since
00:12:24that simply checks whether it ends in .rse but our version ends in the query string so
00:12:29now it thinks it's HTML it stores it in the cache as HTML so the next time a user clicks
00:12:33on the button they actually got the server component data back because it believed that
00:12:37was HTML. The fix for this one was incredibly simple when they're doing the check for whether
00:12:41it ends in .rse they simply now just ignore the query strings. Moving on now to our final
00:12:46category of CVEs we have some cross site scripting ones this is the one I managed to get a recreation
00:12:50of you can see it's a 6.1 out of 10 and it has cross site scripting in before interactive
00:12:55scripts with untrusted input. Basically all that means is in Next.js if I have a script
00:12:59tag that has the strategy of before interactive if I then have another attribute on this that
00:13:03needs some form of untrusted content for example this one is coming from the search params
00:13:08I can do some cross site scripting I can do that by making the user click on a link like
00:13:12this for example where I've embedded a load of content into that search param if someone
00:13:16was to click on that link this is what they would see so they might think they have to
00:13:19log in to the site again and when they click sign in you can see here that was an entirely
00:13:22fake login form that was injected via that search param it essentially allows the attacker
00:13:26to do some JavaScript execution on the victim's machine so I think a more realistic example
00:13:31would be stealing the session cookies of Chrome to log into everything that you had access
00:13:34to this one is a pretty simple example of incorrect escaping and we can see that a bit easier with
00:13:39this more simplified version but all I'm doing in that search param is I'm first closing a
00:13:43script tag then opening up a new script tag with whatever I want to run in it when we hit
00:13:47enter on that you can see I get that alert that says pwned as I showed you with my app
00:13:51the way this one works is we need a next.js script tag that has a strategy of before interactive
00:13:55and you also want some attribute on that script tag that takes in its data from some untrusted
00:13:59source in my case that is coming from the query parameters what this does in next.js is this
00:14:04script tag gets turned into something like this where we have a dangerously set in a HTML
00:14:08I mean it's in the name that it's going to be dangerous and we also have a JSON dot stringify
00:14:12the important thing to know about JSON stringify is that it doesn't escape HTML characters
00:14:17like closing brackets so what's actually happening here is we're taking all of the script tags
00:14:21that we set up in next.js it's looking for the source which is going to be set here and
00:14:24then the rest of the props is going to contain data tracking ID as well as the value that
00:14:29we set in our query parameter so that gets put into this JSON stringify well this actually
00:14:33gets rendered as on the page is something like this so we have our data tracking ID which
00:14:37was the rest of the props but then we also have the string which we inserted via the query
00:14:41params so if we expanded what this actually looks like on the page it looks something like
00:14:45this we have our script tag we have a data tracking ID but then after this we actually
00:14:49have a closing script tag so it ends the script tag that next.js was trying to do and after
00:14:53this we can run whatever script we want on that page then we also have this extra bit
00:14:58at the end here because if we didn't have this analytics would actually be rendered on the
00:15:01page as text as the HTML would see this as text so this essentially just swallows that
00:15:05up so there's no clear signs that something bad is happening on that page and there we
00:15:09go that is 13 CVs for next.js and react this week and how a few of them worked and honestly
00:15:14I don't know what to think about this I hate this and that's coming from a place where two
00:15:18or so years ago every project I did was in next.js and I thought it was the best in the
00:15:22future but it's just seemed to be hurdle after hurdle it feels like they've rushed into a
00:15:26few things and then had to fix them later personally now I am just fully tan stack peeled and also
00:15:31astro when I need a content based site they just seem much simpler to me and to be honest
00:15:35I've also been really liking what cloudflare has been doing lately so I've been slowly migrating
00:15:39my projects over there but I do still have about 20 of them on vacel and I need to go
00:15:43and update what do you think will server components ever be useful or have we tried them and failed
00:15:48let me know in the comments down below why they're subscribe and as always see you in
00:15:51the next one.

Key Takeaway

Next.js and React released patches for 13 vulnerabilities, including a critical 8.6 severity SSRF and a ReDoS exploit that leverages unoptimized string comparisons in the React flight protocol.

Highlights

  • Next.js and React released fixes for 13 new CVEs, with 6 categorized as high-severity vulnerabilities including Denial of Service and Server-Side Request Forgery.

  • A middleware bypass in the Pages Router exists when i18n is enabled because the base URL case fails to trigger the security matcher that protects locale-specific variants.

  • A React Server Components DoS vulnerability allows attackers to force 200 million string comparisons by sending 200,000 random keys and 1,000 pointers in a single payload.

  • Server-Side Request Forgery (SSRF) with an 8.6 severity score allows unauthorized access to internal services like Redis or databases by spoofing the request target via a WebSocket upgrade header.

  • Next.js cache poisoning occurs when a query string appended to a Server Component request bypasses the .rsc extension check, causing the CDN to serve serialized data as HTML.

  • Cross-site scripting (XSS) in beforeInteractive scripts arises from JSON.stringify failing to escape HTML closing tags, allowing malicious script injection via search parameters.

Timeline

Middleware and Proxy Bypass in Pages Router

  • Middleware fails to protect the base path of a route when the i18n configuration is active.
  • Attackers can access sensitive server-side props by requesting the .json data file associated with a build ID.
  • The vulnerability stems from faulty code that excludes the default locale variant from the security matcher.

The system redirects unauthorized users to a login page for locale-specific paths like /en/secret but fails to do so for the base /secret path. By inspecting the next-data script to find the build ID, a user can construct a direct URL to the underlying JSON data. This bypasses the intended middleware protection entirely. Security best practices dictate that sensitive data should be protected by server-side authorization logic rather than relying solely on middleware routing.

Denial of Service via React Server Components

  • A 7.5 severity vulnerability in the react-server-dom package allows attackers to block the server thread for several seconds.
  • The React flight protocol uses a pointer system for deserializing form data that scales quadratically under attack.
  • A payload with 200,000 keys and 1,000 pointers forces the server into 200 million string comparisons.

In the vulnerable version, the server performs a fresh pass over all keys for every pointer defined in the payload. This creates a computational bottleneck where the number of operations is the product of the keys and the pointers. The fix implements a cursor-based system where the cursor moves forward only once through the key list. This optimization reduces the complexity from k*n to n+k, bringing 200 million operations down to approximately 201,000.

Server-Side Request Forgery in Self-Hosted Environments

  • The highest severity vulnerability (8.6) affects self-hosted Next.js applications through the upgrade: websocket header.
  • Attackers can bypass firewalls to access internal services like databases or Redis by manipulating the request target.
  • The resolution involves adding guards to the resolveRoutes function to validate proxy requests before execution.

When a request contains a WebSocket upgrade header, the Next.js server uses the provided request target as the destination for a proxy request. If this target is an internal localhost URL, the Next.js server fetches the data and returns it to the external attacker. Vercel-hosted deployments were not impacted by this specific flaw. The patched code introduces a status code check and a 'finished' boolean to ensure only legitimate internal routes are processed.

Cache Poisoning and XSS Vulnerabilities

  • Cache poisoning occurs because the system incorrectly identifies server component data as HTML when a query string is present.
  • Cross-site scripting (XSS) is possible in scripts using the beforeInteractive strategy with untrusted search parameters.
  • The XSS flaw exists because JSON.stringify does not escape the closing script tag characters.

The cache poisoning flaw relies on the fact that the server component check only looked for the .rsc extension at the very end of the URL. By adding a query string, an attacker tricks the CDN into caching raw serialized data as if it were a standard HTML page. In the XSS case, the use of dangerouslySetInnerHTML combined with unescaped JSON strings allows an attacker to terminate the current script block and start a new, malicious one. This can be exploited to steal session cookies or display fake login forms.

Community Posts

View all posts