Skip to content

fix: add x-action-forwarded guard and E2E tests for server action forwarding loop#1292

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
Divkix:fix/action-forward-loop-guard
May 18, 2026
Merged

fix: add x-action-forwarded guard and E2E tests for server action forwarding loop#1292
james-elicx merged 5 commits into
cloudflare:mainfrom
Divkix:fix/action-forward-loop-guard

Conversation

@Divkix
Copy link
Copy Markdown
Contributor

@Divkix Divkix commented May 17, 2026

Summary

Defensively implements the Next.js server action forwarding loop fix (vercel/next.js@20892dd), as tracked in #1225.

Background

When middleware rewrites a Server Action POST to a page that does not bundle the action, Next.js' multi-worker architecture can forward the request between workers, creating an infinite loop. Next.js fixed this by:

  1. Adding an x-action-forwarded guard that returns 404 + x-nextjs-action-not-found: 1 on subsequent forwards
  2. Propagating the not-found header through to the client so UnrecognizedActionError is thrown

What this PR does

1. Adds x-action-forwarded guard

  • New ACTION_FORWARDED_HEADER constant in headers.ts
  • Added to INTERNAL_HEADERS so it gets stripped from external requests (forgery protection)
  • Guard in handleServerActionRscRequest: returns 404 + x-nextjs-action-not-found: 1 when header is present
  • Guard in handleProgressiveServerActionRequest: same behavior for progressive (multipart) actions
  • Uses a truthy check (any non-empty value blocks), matching Next.js Boolean semantics

2. Unit tests (TDD)

  • 4 new tests in app-server-action-execution.test.ts:
    • Fetch action with x-action-forwarded: 1 -> 404
    • Progressive action with x-action-forwarded: 1 -> 404
    • Any truthy value (e.g., "true") -> 404
    • Absent header -> action executes normally (no false positive)

3. E2E test fixtures

  • /nextjs-compat/action-forward-loop page with a form + server action
  • /nextjs-compat/action-forward-loop-rewrite as middleware rewrite target (no action)
  • error-boundary.tsx using unstable_isUnrecognizedActionError
  • Middleware rewrite rule: POSTs to /action-forward-loop -> /action-forward-loop-rewrite

4. E2E tests

  • "middleware rewrite of action POST does not hang" - verifies action still succeeds
  • "UnrecognizedActionError client contract" - verifies 404 + not-found header via fetch

Why this matters for vinext

Vinext is a single-worker architecture, so the infinite forwarding loop does not apply today. However:

  • The guard is defense-in-depth for future multi-worker or edge deployment scenarios
  • The UnrecognizedActionError client contract is part of the public Next.js API surface that vinext reimplements
  • The E2E tests ensure parity with Next.js behavior and catch regressions

Test results

  • Unit: 946 passed (35 action tests + 911 shims tests), 0 failures
  • Lint/typecheck: all clean
  • Simplify review: no issues found

Closes

Closes #1225

Divkix added 2 commits May 17, 2026 02:53
…ng loops

Defensively implements the Next.js server action forwarding loop fix.
- Adds ACTION_FORWARDED_HEADER constant to headers.ts
- Adds x-action-forwarded to INTERNAL_HEADERS blocklist
- Adds guard to handleServerActionRscRequest and handleProgressiveServerActionRequest
- Returns 404 + x-nextjs-action-not-found when header is present

Ported from Next.js: vercel/next.js@20892dd
- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite
- Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target
- Adds error boundary component using unstable_isUnrecognizedActionError
- Adds E2E tests: middleware rewrite + action POST, not-found header contract
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 17, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1292

commit: 960d1c9

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The core server-side guard implementation is clean and correct — the header constant, INTERNAL_HEADERS inclusion, and guard placement in both handleServerActionRscRequest and handleProgressiveServerActionRequest all look good. The unit tests are well-structured and cover the right cases.

However, the E2E tests have structural problems that will cause failures and don't actually exercise the new code or the fixture error boundary.

Issues

  1. First E2E test will fail at runtime. The page renders <p id="action-result"></p> as a static empty element, and nothing populates it with the action's return value. The form uses action={runAction} directly (not useActionState), so React invokes the action on submit but the return value "action-ok" is discarded — #action-result will always be empty. The test waits for it to contain "action-ok" and will time out.

  2. Second E2E test doesn't test the forwarding guard. It sends a fetch with x-rsc-action: "stale-action-id" but without x-action-forwarded. This exercises the pre-existing "unknown action ID → 404" path, not the new guard. Furthermore, since x-action-forwarded is in INTERNAL_HEADERS, it would be stripped from external requests before reaching the action handler — so you can't test the guard via an external fetch from page.evaluate anyway.

  3. Error boundary fixture is dead code. Neither E2E test verifies that #action-not-found-error renders. The error-boundary.tsx file is included but never exercised. Compare with the original Next.js test which clicks the button and asserts await browser.waitForElementByCss('#action-not-found-error') — that's the actual user-facing behavior being tested.

  4. Misleading test name. "UnrecognizedActionError is caught by client error boundary" describes checking the error boundary rendering, but the test only checks a raw fetch response.

Suggestion

The Next.js test is elegant because middleware naturally triggers the forwarding path. In vinext's single-worker architecture the forwarding loop can't happen organically, but the E2E tests should still meaningfully exercise the guard. Consider:

  • For the first test: either use useActionState to render the action result, or just test that the form submission doesn't hang (verify the page doesn't time out and the button remains interactive).
  • For the second test: if you want to test the x-action-forwarded guard specifically, you'd need the header to survive past the INTERNAL_HEADERS stripping — which only works for internally-forwarded requests. Consider testing the UnrecognizedActionError client contract via the stale-action-id scenario (which is a valid E2E test), but rename it to match what it actually tests.

Run action
</button>
</form>
<p id="action-result"></p>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This <p> will always be empty. The form uses action={runAction} directly, so React calls runAction() on submit but discards the return value — there's no mechanism to render "action-ok" into the DOM.

The E2E test at server-actions.spec.ts:250 waits for #action-result to contain "action-ok" and will time out.

To display the action result, you'd need useActionState:

"use client";
import { useActionState } from "react";
import { ErrorBoundary } from "./error-boundary";
import { runAction } from "./actions";

export default function Page() {
  const [result, formAction] = useActionState(runAction, "");
  return (
    <main>
      <h1 id="action-forward-loop-page">Action Forward Loop Test</h1>
      <ErrorBoundary>
        <form action={formAction}>
          <button id="run-action" type="submit">Run action</button>
        </form>
        <p id="action-result">{result}</p>
      </ErrorBoundary>
    </main>
  );
}

(The action signature would also need to change to accept the previous state as the first argument.)

await expect(async () => {
const text = await page.locator("#action-result").textContent();
expect(text).toContain("action-ok");
}).toPass({ timeout: 10_000 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion will time out because #action-result is always empty — see comment on page.tsx. The page has no client-side state management to render the action's return value.


// Ported from Next.js: test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts
test("UnrecognizedActionError is caught by client error boundary", async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name says "UnrecognizedActionError is caught by client error boundary" but the test never checks the error boundary. It only verifies the raw fetch response (404 + header). The error-boundary.tsx fixture and #action-not-found-error element are never exercised.

Also, this doesn't test the new x-action-forwarded guard — the fetch doesn't include that header. It tests the pre-existing "unknown action ID → 404" code path (which already existed before this PR).

Consider either:

  1. Renaming to reflect what it actually tests (e.g., "stale action ID returns 404 with action-not-found header"), or
  2. Actually testing the error boundary by triggering a form submit that results in UnrecognizedActionError, matching the original Next.js test at test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts

const res = await fetch(`${base}/nextjs-compat/action-forward-loop`, {
method: "POST",
headers: {
"x-rsc-action": "stale-action-id",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: x-action-forwarded is in INTERNAL_HEADERS and will be stripped from this external request before it reaches the action handler. So even if you added "x-action-forwarded": "1" here, the guard wouldn't fire — which is correct security behavior (prevents external forgery), but means this approach can't test the guard via E2E.

@@ -0,0 +1,27 @@
"use client";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error boundary is well-implemented (matches the Next.js test fixture), but it's currently dead code — no E2E test verifies that #action-not-found-error renders. The Next.js test exercises it by clicking the button and asserting await browser.waitForElementByCss('#action-not-found-error').

In vinext's single-worker model the action is found locally (despite the middleware rewrite), so clicking the button won't trigger UnrecognizedActionError. The error boundary fixture is useful documentation of the API surface, but the tests should acknowledge this architectural difference rather than leaving it unexercised.

}

// Defensive guard: prevent infinite forwarding loops. See handleServerActionRscRequest.
if (options.request.headers.get(ACTION_FORWARDED_HEADER)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The guard placement is correct (before CSRF validation — no point validating origin if we're going to 404). One minor observation: in handleServerActionRscRequest below the guard passes options.actionId to createActionNotFoundResponse, but here it passes null because progressive actions don't have an actionId at this point. This is consistent and correct — just noting it for future readers.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 18, 2026

Review submitted. Here's a summary of the feedback:

Core implementation (LGTM): The x-action-forwarded guard in headers.ts and app-server-action-execution.ts is clean — correct header constant, proper INTERNAL_HEADERS inclusion for forgery protection, and guard placement before CSRF validation in both handler paths. Unit tests cover the right cases.

E2E tests (request changes):

  1. First test will time out — the page has <p id="action-result"></p> as a static empty element with no mechanism to display the action's return value (needs useActionState).

  2. Second test doesn't exercise the new guard — it sends a fetch with a stale action ID but without x-action-forwarded, testing pre-existing behavior rather than the new code. The header would also be stripped from external requests by INTERNAL_HEADERS anyway.

  3. Error boundary fixture is dead code — neither test verifies #action-not-found-error renders, unlike the original Next.js test.

github run

Divkix added 3 commits May 18, 2026 09:25
- page.tsx: use useActionState so action result renders into #action-result
- actions.ts: update runAction signature for useActionState compat
- Remove dead error-boundary.tsx (never exercised in single-worker model)
- Rename second test to match actual behavior (stale action ID -> 404)
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous review's feedback has been thoroughly addressed. All CI checks pass. LGTM.

What was fixed since last review:

  • Page now uses useActionState correctly, so the first E2E test works (action result renders into #action-result)
  • Action signature updated to accept _prevState as first arg (matching useActionState contract)
  • Dead error-boundary.tsx fixture removed
  • Second E2E test renamed from misleading "UnrecognizedActionError is caught by client error boundary" to accurate "stale action ID returns 404 with x-nextjs-action-not-found header"
  • Honest comments added about what each E2E test actually exercises vs the architectural limitations

Core implementation is clean — header constant, INTERNAL_HEADERS inclusion for forgery protection, guard placement before CSRF validation in both handleServerActionRscRequest and handleProgressiveServerActionRequest. Unit tests cover the right cases.

One minor nit on a fixture comment below, but not blocking.

// to /nextjs-compat/action-forward-loop-rewrite so the receiving page does not
// bundle the action. Without the x-action-forwarded guard, a multi-worker
// deployment would loop indefinitely. In vinext's single-worker model, the
// guard still fires defensively when the header is injected.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this comment is slightly misleading — "the guard still fires defensively when the header is injected" implies the middleware or vinext injects x-action-forwarded during this rewrite, which it doesn't. The guard only fires when a request already carries the header (which would come from an inter-worker forward in a multi-worker deployment). In vinext's single-worker model, the guard never fires organically.

Suggestion:

Suggested change
// guard still fires defensively when the header is injected.
// guard still applies as defense-in-depth if the header is ever present.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 18, 2026

Review submitted. Here's a summary:

Approved — the previous review's feedback has been thoroughly addressed and all CI checks pass.

Key changes since last review:

  • Page correctly uses useActionState now, so the first E2E test works
  • Action signature properly accepts _prevState as first arg
  • Dead error-boundary.tsx removed
  • Second E2E test renamed to accurately describe what it tests (stale action ID path, not the forwarding guard)
  • Honest comments added about architectural limitations of E2E testing the guard in single-worker mode

Core implementation was already solid from the start — the header constant, INTERNAL_HEADERS forgery protection, and guard placement in both handler paths are all correct.

One minor nit on a middleware fixture comment that's slightly misleading about when the guard fires, but not blocking.

github run

@james-elicx james-elicx merged commit a85c2c1 into cloudflare:main May 18, 2026
28 checks passed
@Divkix Divkix deleted the fix/action-forward-loop-guard branch May 18, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[nextjs-tracker] Server action forwarding loop with middleware rewrites

2 participants