Skip to content

fix: filter internal Next.js headers from inbound requests#1056

Merged
james-elicx merged 8 commits intocloudflare:mainfrom
NathanDrake2406:fix/filter-internal-headers-security
May 5, 2026
Merged

fix: filter internal Next.js headers from inbound requests#1056
james-elicx merged 8 commits intocloudflare:mainfrom
NathanDrake2406:fix/filter-internal-headers-security

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • Adds filterInternalHeaders() to strip internal Next.js headers from inbound requests at all entry points, matching Next.js behavior
  • Prevents attackers from forging x-nextjs-data, x-matched-path, x-now-route-matches, x-next-resume-state-length, and x-middleware-* headers to influence routing or impersonate internal state

Problem

Next.js calls filterInternalHeaders() at the router-server entry point before any handler or middleware sees the request. This strips the INTERNAL_HEADERS list from the request.

vinext was not filtering any of these headers. All four headers reached middleware as user-controlled request headers.

Fix

Added INTERNAL_HEADERS constant and filterInternalHeaders(headers: Headers) to server/request-pipeline.ts, ported directly from Next.js. Wired into every request entry point:

Entry point File
App Router handler server/app-rsc-handler.ts
CF Worker App Router entry server/app-router-entry.ts
Node prod-server (App + Pages) server/prod-server.ts (nodeToWebRequest + Pages path)
Dev server Pages Router middleware path index.ts
Generated Pages Router worker entry deploy.ts

Filtering runs before middleware, config matching, and routing — no subsystem sees forged internal headers. The x-middleware-* headers are stripped from requests but NOT from middleware responses — the middleware protocol (which reads x-middleware-* from Response.headers, not Request.headers) continues to work correctly.

Tests

Added 8 unit tests in tests/request-pipeline.test.ts:

  • Exact header list matches Next.js INTERNAL_HEADERS
  • All internal headers stripped while preserving others
  • Case-insensitive deletion (HTTP spec compliance)
  • No-op on empty/clean headers
  • Forged x-middleware-rewrite + x-middleware-next stripped
  • Subset stripping preserves unrelated headers

All related tests pass (request-pipeline: 78, deploy: 210, app-router: 300, features: 267, pages-router: 200).

Next.js strips internal headers (x-nextjs-data, x-matched-path,
x-now-route-matches, x-next-resume-state-length, and x-middleware-*)
via filterInternalHeaders() at the router-server entry point before
any handler or middleware sees the request. This prevents attackers
from forging these headers to influence routing or impersonate
internal data fetches.

vinext was not filtering any of these headers from inbound requests,
so all four reached middleware as user-controlled request headers.

Ported from Next.js INTERNAL_HEADERS list and filterInternalHeaders():
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/server-ipc/utils.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts

Added filterInternalHeaders() to server/request-pipeline.ts and wired
it into all request entry points:
- App Router handler (app-rsc-handler.ts)
- CF Worker App Router entry (app-router-entry.ts)
- Node prod-server (nodeToWebRequest + Pages Router path)
- Dev server Pages Router middleware path (index.ts)
- Generated Pages Router worker entry (deploy.ts)

Filtering runs before middleware, config matching, and routing so no
subsystem sees forged internal headers. The x-middleware-* headers are
stripped from requests but NOT from middleware responses — the
middleware protocol (which reads x-middleware-* from Response headers,
not request headers) continues to work correctly.
Copilot AI review requested due to automatic review settings May 4, 2026 15:52
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

Open in StackBlitz

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

commit: 070d555

Request.headers is immutable in Cloudflare Workers / Miniflare /
workerd environments. The previous in-place mutation via
headers.delete() threw 'Can't modify immutable headers', causing
every request to 500 in the cloudflare-dev E2E suite.

Changed filterInternalHeaders() to always return a new Headers
copy (never mutates the input). Call sites that use request.headers
now rebuild the Request with the filtered headers, matching the
existing pattern in applyMiddlewareRequestHeaders() at
config-matchers.ts:495-502.
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31e22ad169

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +58 to +61
request = new Request(request.url, {
method: request.method,
headers: filteredHeaders,
body: request.body,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve original Request metadata when filtering headers

Rebuilding the incoming Worker request with new Request(request.url, { method, headers, body }) drops non-header request metadata (notably redirect, signal, and Worker-specific cf data), because a URL-based constructor defaults redirect to follow instead of the original incoming manual mode. This can change app behavior for middleware/route code that forwards the request (fetch(request)), relies on client-disconnect abort signals, or reads Cloudflare request context. The same pattern appears in other new filtering call sites, so this should be switched to cloning from the original Request object (overriding only headers) to keep runtime semantics intact.

Useful? React with 👍 / 👎.

handleAppRscRequest() is an internal handoff point, not a trust
boundary. Filtering here broke the x-vinext-mw-ctx handoff in hybrid
app+pages dev mode because cloned Request objects could drop
coordination state from the connect handler, causing middleware to
run twice.

Moved filtering to the top of createAppRscHandler(), the true App
Router request boundary, before headersContextFromRequest() and
requestContextFromRequest() capture any context. This also fixes the
security gap where forged headers were captured in the request context
before filtering ran.
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex help me fix CI pls

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex help me fix CI pls

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

…rsc-handler

srvx's NodeRequestHeaders reads from rawHeaders for iteration but falls back
to req.headers for .get() / .has(). In the dev server we add x-vinext-mw-ctx
to req.headers after the Request is built, so it is visible to .get() but lost
when filterInternalHeaders iterates. Read it BEFORE iterating so
applyForwardedMiddlewareContext can skip middleware and avoid double execution.

Fixes CI failure on PR cloudflare#1056.
cloneRequestWithHeaders now copies the Workers cf property in both
the primary (try) path and the fallback path. Added 8 unit tests
locking down method, URL, redirect, signal, body, cf, and combined
metadata preservation to prevent regression to the broken
URL-constructor pattern.
@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: filter internal Next.js headers from inbound requests

Good security fix. The INTERNAL_HEADERS list matches Next.js canary exactly, the cloneRequestWithHeaders helper correctly addresses the Codex bot's earlier feedback about preserving Request metadata, and all CI is green. A few observations below — mostly nits and one minor suggestion.

Double-filtering

Every App Router path calls filterInternalHeaders twice: once at the entry point (worker entry, prod-server, or dev connect middleware) and once in createAppRscHandler. This is harmless — the second pass finds nothing to strip — and the comment in app-rsc-handler.ts explains it's intentional defense-in-depth. That said, if this ever becomes a hot path concern, consider a Set<string> for the lookup (see inline comment).

Overall

The change is correct, well-tested, and comprehensive across all entry points. The x-vinext-mw-ctx save-and-restore in app-rsc-handler.ts is a nice detail that avoids breaking the hybrid dev-mode middleware forwarding. Ship it.

export function filterInternalHeaders(headers: Headers): Headers {
const filtered = new Headers();
for (const [key, value] of headers) {
if (!INTERNAL_HEADERS.includes(key.toLowerCase())) {
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: INTERNAL_HEADERS.includes() is an O(n) linear scan on every header of every request. With 10 internal headers and typical requests carrying ~15-20 headers, that's ~150-200 string comparisons per request (and this runs twice on App Router paths due to the defense-in-depth pattern in createAppRscHandler).

Not a real problem at this scale, but if you ever want to tighten it:

Suggested change
if (!INTERNAL_HEADERS.includes(key.toLowerCase())) {
if (!internalHeaderSet.has(key.toLowerCase())) {

with a const internalHeaderSet = new Set(INTERNAL_HEADERS) defined at module scope. Low priority — just noting for future.

const mwCtx = rawRequest.headers.get("x-vinext-mw-ctx");
const filteredHeaders = filterInternalHeaders(rawRequest.headers);
if (mwCtx !== null) {
filteredHeaders.set("x-vinext-mw-ctx", mwCtx);
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.

The save-and-restore of x-vinext-mw-ctx is correct and necessary — good catch on the srvx NodeRequestHeaders iteration quirk.

One edge: filterInternalHeaders copies headers by iterating rawRequest.headers, which srvx's NodeRequestHeaders implements via rawHeaders. If any middleware adds x-vinext-mw-ctx to req.headers after the Request is built (which index.ts:2566 does), iteration won't see it, but .get() will — which is exactly the scenario the comment describes. The explicit .get() before iteration is the right fix.

Worth noting: x-vinext-mw-ctx is only consumed in non-production mode (app-middleware.ts:130 checks NODE_ENV !== 'production'), so even if an attacker forges it in production, it's a no-op. But stripping Next.js internal headers while preserving vinext's own internal header is still the right layering.

// (which reads req.headers directly) must see clean headers.
const nodeRequestHeaders = filterInternalHeaders(rawHeaders);
for (const header of INTERNAL_HEADERS) {
delete req.headers[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.

This is the only call site that mutates the Node req.headers object in addition to filtering the Headers copy. This is necessary because Pages Router SSR reads req.headers directly (not the Web Headers object), but it's a subtle asymmetry worth a brief comment explaining why both operations are needed.

The existing comment says "Both the middleware Request ... and the SSR handler ... must see clean headers" which partially covers it, but the SSR handler reads req.headers (the Node object), not the Headers instance — that's the actual reason for the delete loop. Consider making that explicit:

Suggested change
delete req.headers[header];
for (const header of INTERNAL_HEADERS) {
// SSR handlers read req.headers (the Node object) directly,
// so internal headers must be removed from it too.
delete req.headers[header];
}

return filtered;
}

function getRequestCf(request: Request): unknown | undefined {
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: getRequestCf returns unknown | undefined which simplifies to just unknown. The function body also has a redundant conditional — Reflect.get already returns undefined when the property doesn't exist, so the === undefined ? undefined : cf check is a no-op. This is fine for clarity but could be simplified to:

function getRequestCf(request: Request): unknown {
  return Reflect.get(request, "cf");
}

Not blocking.

// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-server.ts

describe("INTERNAL_HEADERS", () => {
it("matches Next.js's exact header list", () => {
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 is a good safeguard against drift, but it's asserting the exact same array literal that defines INTERNAL_HEADERS in the source — if someone adds a header in one place and forgets the other, only the test would catch it. That's the intent, but consider adding a comment like "If this test fails, update BOTH this test AND the source list" to make the maintenance contract explicit.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

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

Overall: Ship it. This is a correct and well-implemented security fix.

Key findings:

  1. Header list is accurate — verified against Next.js canary server-ipc/utils.ts, the INTERNAL_HEADERS list matches exactly.

  2. All entry points covered — Every request path (App Router dev/prod, Pages Router dev/prod, Cloudflare Workers entries) has filterInternalHeaders wired in before any middleware or handler sees the request. No path was missed.

  3. Double-filtering is intentional — App Router paths filter twice (once at entry, once in createAppRscHandler). The second pass is a no-op but serves as defense-in-depth. Documented in comments.

  4. cloneRequestWithHeaders is well-designed — Addresses the Codex bot's earlier feedback about preserving redirect, signal, cf metadata. The try/catch fallback handles cross-realm Request incompatibilities gracefully.

  5. x-vinext-mw-ctx handling is correct — The save-before-filter pattern in app-rsc-handler.ts accounts for a subtle srvx iteration quirk, and the header is only consumed in dev mode anyway.

Inline comments were mostly nits: Set vs Array.includes for lookup performance, a redundant getRequestCf conditional, and a couple of comment clarification suggestions.

github run

@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: filter internal Next.js headers from inbound requests

This is a correct and well-structured security fix. The implementation faithfully ports Next.js's filterInternalHeaders behavior, covers all request entry points, and the cloneRequestWithHeaders helper properly addresses the Codex bot's earlier feedback about preserving Request metadata.

What I verified

  1. Header listINTERNAL_HEADERS matches Next.js canary's server-ipc/utils.ts exactly.
  2. Entry point coverage — All five request paths (App Router worker entry, App Router RSC handler, Pages Router dev connect middleware, Pages Router prod-server nodeToWebRequest, Pages Router prod-server startPagesRouterServer) filter before any middleware/handler runs.
  3. cloneRequestWithHeaders — The try/catch pattern handles both Workers (clone-from-Request) and Node/undici (manual init) correctly. The cf re-attach via Object.defineProperty is sound — in Workers the clone may already have cf, but re-defining it with the same value is harmless and ensures correctness in the fallback path.
  4. x-vinext-mw-ctx preservation — The save-before-filter pattern in app-rsc-handler.ts correctly handles the srvx NodeRequestHeaders iteration quirk. This header is only consumed in dev mode, so even if an attacker forges it in production, it's a no-op.
  5. Tests — Good coverage: exact list parity, case-insensitivity, no-op on clean headers, subset stripping, forged middleware headers, empty headers, and comprehensive cloneRequestWithHeaders metadata preservation.

The previous review's nits (Set vs includes, redundant getRequestCf conditional, comment clarifications) are all valid but non-blocking. One minor additional observation below.

Ship it.

// new Request() does not copy Workers-specific cf, so re-attach it.
Object.defineProperty(cloned, "cf", {
value: cf,
enumerable: true,
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.

Minor: In the Workers runtime, new Request(request, { headers }) does preserve cf on the clone. So in the happy path (line 616), the Object.defineProperty here overwrites cf with the identical value. This is harmless — the configurable: true on the original means defineProperty succeeds — but it's worth knowing this is a redundant-but-safe operation in the Workers case and only truly necessary for the catch/fallback path.

Not blocking — just noting for future readers.

// Builds a new Headers — Request.headers is immutable in Workers.
{
const filteredHeaders = filterInternalHeaders(request.headers);
request = cloneRequestWithHeaders(request, filteredHeaders);
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: this request then flows into rscHandler (line 74) which calls createAppRscHandler, where filterInternalHeaders runs again. The double-filter is intentional defense-in-depth (documented in app-rsc-handler.ts), but the second pass is guaranteed to be a no-op here since the same headers were just stripped. Correct as-is.

// (which reads req.headers directly) must see clean headers.
const nodeRequestHeaders = filterInternalHeaders(rawHeaders);
for (const header of INTERNAL_HEADERS) {
delete req.headers[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.

+1 to the previous review's suggestion here. The delete req.headers[header] loop is necessary because the Pages Router SSR handler reads the Node IncomingMessage.headers object directly (not the Web Headers instance). A one-line comment clarifying that would save future readers from wondering why both operations are needed:

Suggested change
delete req.headers[header];
// Pages Router SSR reads req.headers (Node object) directly,
// so internal headers must be removed from it too.
delete req.headers[header];

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted with approval. Here's a summary:

Approved — this is a correct and comprehensive security fix.

Key findings:

  1. Header list matches Next.js exactly — the INTERNAL_HEADERS array is a faithful port from server-ipc/utils.ts
  2. All 5 entry points covered — App Router worker entry, App RSC handler, Pages Router dev middleware, Pages Router prod-server nodeToWebRequest, and Pages Router prod-server startPagesRouterServer all filter before any handler runs
  3. cloneRequestWithHeaders is well-designed — try/catch handles both Workers (clone-from-Request) and Node/undici (manual init), with cf re-attach for Workers metadata
  4. x-vinext-mw-ctx save-and-restore is correct — handles the srvx iteration quirk properly
  5. Tests are thorough — 8 tests covering exact list parity, case-insensitivity, no-op on clean headers, forged middleware headers, empty headers, and full cloneRequestWithHeaders metadata preservation

I left 3 inline comments: a note about redundant-but-harmless cf re-attach in the Workers happy path, confirmation that double-filtering on App Router paths is intentional defense-in-depth, and a +1 to the previous review's suggestion to clarify the delete req.headers[header] comment in index.ts.

github run

@james-elicx james-elicx merged commit d6c270f into cloudflare:main May 5, 2026
30 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/filter-internal-headers-security branch May 6, 2026 04:30
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.

2 participants