Denial of Service (DoS) via server action forwarding loop in middleware-forwarded requests

HIGH
vercel/next.js
Commit: 20892dd44e13
Affected: Next.js 16.x prior to the fix, specifically 16.2.2 and earlier
2026-05-14 15:28 UTC

Description

This commit implements a real security fix for a Denial of Service (DoS) scenario caused by an infinite forward loop in server action handling when middleware rewrites a POST to a route that doesn’t bundle the action. The changes add: (1) a guard in action forwarding that prevents forwarding a request that already carried x-action-forwarded, avoiding repeated forwards across workers; (2) logic to propagate a not-found header and 404 response so the client gets a proper UnrecognizedActionError instead of a generic error. Together these prevent resource exhaustion (memory/time) from forwarding loops.

Proof of Concept

PoC reproduction (pre-fix behavior): Setup a Next.js app using app-dir with a middleware that rewrites server action POSTs from /with-action to /without-action, where /without-action does not bundle the action. The forward path will bounce the request between workers that host/bundle the action and those that don’t, potentially looping until network timeouts or memory pressure. Files to add (example): 1) middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { // Only rewrite POSTs to the action route to a route that does not bundle it if (request.method === 'POST' && request.nextUrl.pathname === '/with-action') { const url = request.nextUrl.clone() url.pathname = '/without-action' return NextResponse.rewrite(url) } return NextResponse.next() } export const config = { matcher: '/with-action', } 2) app-dir/pages (or app-router) setup: - /with-action/page.tsx with a server action attached to a form (use server) wrapped in an error boundary that looks for an UnrecognizedActionError - /without-action/page.tsx a page that does not bundle the action (no server action) Example snippet for with-action (server action on form): 'use client' export default function Page() { return ( <form action={async () => { 'use server' }}> <button id="run-action">Run action</button> </form> ) } 3) Client-side test (optional): - Navigate to /with-action and click the Run action button - Observe repeated forwarding attempts between /with-action and /without-action as the middleware rewrites the POST, causing a loop if the guard is not present Expected post-fix behavior (what you should observe after applying this patch): - The server detects x-action-forwarded (via header) and does not forward again, preventing the loop - If the forwarded worker cannot find the action, the response includes x-nextjs-action-not-found header and 404 with a clear UnrecognizedActionError on the client, avoiding a generic error Notes: - The test in the commit (test/e2e/app-dir/action-forward-loop) reproduces the scenario and asserts the presence of the action-not-found error when the loop is broken by the fix. - The core exploit payload here is the forwarding loop induced by middleware rewrites in the absence of the new guard; the PoC above demonstrates how a forward loop is triggered and why the guard is necessary.

Commit Details

Author: Hendrik Liebau

Date: 2026-05-14 15:01 UTC

Message:

Fix server action forwarding loop with middleware rewrites (#93792) Reopens #93710 from a branch on this repo so the required deploy test matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and those deploy jobs are required checks. All commits and credit carry forward from the original PR by @elishagreenwald, which built on earlier attempts by @LiamBoz in #84525 and @claygeo in #92053. When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504). The fix is two related changes on the forwarding code path. The first is an `!actionWasForwarded` guard in `action-handler.ts` so a request carrying `x-action-forwarded: 1` is never forwarded a second time. The second is a new branch in `createForwardedActionResponse` that copies the `x-nextjs-action-not-found` header and 404 status onto the originating response when the forwarded worker can't find the action either; without it the client would see a generic "unexpected response" error instead of the `UnrecognizedActionError` that the rest of the framework expects. The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still render the page normally; the page renders a `<form>` with an inline server action wrapped in a React error boundary that branches on `unstable_isUnrecognizedActionError`. The test clicks the button and waits for `#action-not-found-error`. Without the loop guard the test times out at 10s, and without the pass-through the boundary catches a generic error and the assertion fails — so both changes are individually load-bearing. This is a short-term fix. The mid-term direction is to remove server-side action forwarding altogether and have the client dispatch each Server Action to the route that actually bundles it, which makes the entire forwarding code path (and this bug surface) unnecessary. #90549 is the draft exploring that approach. Fixes #84504 Closes #93710 Closes #84525 Closes #92053 --------- Co-Authored-By: Elisha Greenwald <ejgreenwald@gmail.com> Co-Authored-By: LiamBoz <thisamazingnow@gmail.com> Co-Authored-By: kmaclip <kmartclips@proton.me>

Triage Assessment

Vulnerability Type: Denial of Service (DoS)

Confidence: HIGH

Reasoning:

The patch adds a guard to prevent forwarding a server action more than once and introduces handling to avoid looping forwarding when middleware rewrites occur. This prevents a potential denial-of-service scenario where repeated forwards could exhaust memory or timeouts. The changes directly target the forwarding logic that could be exploited, addressing a security impact rather than purely functional improvements.

Verification Assessment

Vulnerability Type: Denial of Service (DoS) via server action forwarding loop in middleware-forwarded requests

Confidence: HIGH

Affected Versions: Next.js 16.x prior to the fix, specifically 16.2.2 and earlier

Code Diff

diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 5048ddba614..a5d75316b2f 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -279,9 +279,18 @@ async function createForwardedActionResponse( } return new FlightRenderResult(response.body!) - } else { - // Since we aren't consuming the response body, we cancel it to avoid memory leaks - response.body?.cancel() + } + + // Since we aren't consuming the response body, we cancel it to avoid memory leaks + response.body?.cancel() + + // Pass the action-not-found marker through so the client throws + // UnrecognizedActionError instead of a generic "unexpected response". + if (response.headers.get(NEXT_ACTION_NOT_FOUND_HEADER) === '1') { + res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1') + res.setHeader('content-type', 'text/plain') + res.statusCode = 404 + return RenderResult.fromStatic('Server action not found.', 'text/plain') } } catch (err) { // we couldn't stream the forwarded response, so we'll just return an empty response @@ -706,11 +715,15 @@ export async function handleAction({ const actionWasForwarded = Boolean(req.headers['x-action-forwarded']) - if (actionId) { + // Only attempt to forward if this request has not already been forwarded. + // Otherwise middleware that rewrites the action POST can cause the receiving + // worker to forward again, looping indefinitely. + if (actionId && !actionWasForwarded) { const forwardedWorker = selectWorkerForForwarding(actionId, page) - // If forwardedWorker is truthy, it means there isn't a worker for the action - // in the current handler, so we forward the request to a worker that has the action. + // If forwardedWorker is truthy, it means there isn't a worker for the + // action in the current handler, so we forward the request to a worker that + // has the action. if (forwardedWorker) { return { type: 'done', diff --git a/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts b/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts new file mode 100644 index 00000000000..7d90d696e38 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts @@ -0,0 +1,13 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('action forward loop prevention', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('renders the action-not-found error when a rewrite sends the action POST to a route that does not bundle it', async () => { + const browser = await next.browser('/with-action') + await browser.elementById('run-action').click() + await browser.waitForElementByCss('#action-not-found-error') + }) +}) diff --git a/test/e2e/app-dir/action-forward-loop/app/layout.tsx b/test/e2e/app-dir/action-forward-loop/app/layout.tsx new file mode 100644 index 00000000000..888614deda3 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + <html> + <body>{children}</body> + </html> + ) +} diff --git a/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx b/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx new file mode 100644 index 00000000000..b581b6d1e54 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/with-action/error-boundary.tsx @@ -0,0 +1,27 @@ +'use client' + +import { Component, type ReactNode } from 'react' +import { unstable_isUnrecognizedActionError } from 'next/navigation' + +interface State { + error: unknown +} + +export class ErrorBoundary extends Component<{ children: ReactNode }, State> { + state: State = { error: null } + + static getDerivedStateFromError(error: unknown): State { + return { error } + } + + render() { + const { error } = this.state + if (error === null) { + return this.props.children + } + if (unstable_isUnrecognizedActionError(error)) { + return <p id="action-not-found-error">Server action not found</p> + } + return <p id="unexpected-error">Unexpected error</p> + } +} diff --git a/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx b/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx new file mode 100644 index 00000000000..02195f78690 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/with-action/page.tsx @@ -0,0 +1,18 @@ +import { ErrorBoundary } from './error-boundary' + +export default function Page() { + return ( + <main> + <h1 id="with-action-page">with-action</h1> + <ErrorBoundary> + <form + action={async () => { + 'use server' + }} + > + <button id="run-action">Run action</button> + </form> + </ErrorBoundary> + </main> + ) +} diff --git a/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx b/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx new file mode 100644 index 00000000000..d34252e0929 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/app/without-action/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return <main>without-action</main> +} diff --git a/test/e2e/app-dir/action-forward-loop/next.config.js b/test/e2e/app-dir/action-forward-loop/next.config.js new file mode 100644 index 00000000000..807126e4cf0 --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/action-forward-loop/proxy.ts b/test/e2e/app-dir/action-forward-loop/proxy.ts new file mode 100644 index 00000000000..a9f2b21f18a --- /dev/null +++ b/test/e2e/app-dir/action-forward-loop/proxy.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +// Only rewrite action POSTs so the user can still GET the page to see the +// button. The receiving worker is /without-action, which does not bundle the +// action — so without the loop guard, the worker forwards the action back to +// /with-action, which is rewritten here again, and so on. +export function proxy(request: NextRequest) { + if ( + request.method === 'POST' && + request.nextUrl.pathname === '/with-action' + ) { + const url = request.nextUrl.clone() + url.pathname = '/without-action' + return NextResponse.rewrite(url) + } + return NextResponse.next() +} + +export const config = { + matcher: '/with-action', +}
← Back to Alerts View on GitHub →