fix(middleware): stop restoring credentials middleware deleted before external rewrites - #2739
Conversation
… external rewrites `x-middleware-override-headers` carries the complete post-middleware header set, not a diff: `NextResponse.next`/`rewrite` encode every key of the Headers object middleware passes, and Next.js deletes any request header missing from that list. Absence means deleted. `preserveCredentialHeaders` (cloudflare#1121) read a short override list as a "partial" override and copied the base request's `cookie`/`authorization` back in. The documented deletion pattern — clone `request.headers`, delete the credential, return `NextResponse.rewrite(externalUrl, { request: { headers } })` — produces exactly such a list, so the option resurrected the stripped credentials. It was enabled only for external rewrites, so `proxyExternalRequest` then forwarded first-party session cookies and bearer tokens to a cross-origin target. The "partial override" the option guarded against cannot occur: `encodeMiddlewareRequestHeaders` is the only producer of the override list and always emits the full key set. Remove the option and restore Next.js-exact deletion semantics. Covered end to end (fixture middleware deleting credentials before an external rewrite), at the proxy boundary, and in the Pages Router pipeline.
|
@codex review |
commit: |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Performance benchmarksCompared 1 improved · 0 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: LGTM — correct and well-justified
Verified the core claim against the Next.js source. resolve-routes.ts (getResolveRoutes) does exactly what the PR describes:
if (middlewareHeaders['x-middleware-override-headers']) {
// Delete headers.
for (const key of Object.keys(req.headers)) {
if (!overriddenHeaders.has(key)) {
delete req.headers[key]
}
}
// Update or add headers.
...
}The override list is the complete post-middleware header set, and absence = deletion. Restoring cookie/authorization from the base request contradicted this and, because it was gated to external rewrites only, leaked first-party credentials cross-origin. Removing preserveCredentialHeaders restores Next.js-exact semantics.
Verification performed
encodeMiddlewareRequestHeadersis the only producer ofx-middleware-override-headers(confirmed via grep), and it always emits the full key set ([...requestHeaders.keys()]). It is only invoked wheninit.request.headersis present (shims/server.ts:317,:331). So the "partial override list" #1121 guarded against genuinely cannot occur through the public API — deleting the option is the right call, not a narrower guard.- The
nulloverride path is preserved (middleware-request-headers.ts:84-95):NextResponse.next()/rewrite()without arequestfield emits no override header, so base headers pass through unchanged. This matches Next.js (the deletion block is entirely skipped when the header is absent). - Other consumers were already on default behavior.
app-route-handler-runtime.ts:310,shims/headers.ts:646, andapp-rsc-entry.ts:1270never passedpreserveCredentialHeaders, so unifying on the default introduces no divergence for them. - No unused imports.
isExternalUrlis still used in 8 other places inpages-request-pipeline.ts. - Tests pass and are meaningful. Ran all four affected suites locally — the new tests drive real
NextResponse.rewrite/dev-server paths rather than hand-encoding override lists, which is what let the #1121 bug slip through:tests/request-pipeline.test.ts-t buildRequestHeadersFromMiddlewareResponse — 2 passedtests/pages-request-pipeline.test.ts-t "external rewrite credential headers" — 2 passedtests/shims.test.ts-t "does not forward credentials the middleware deleted" — 1 passedtests/app-router-external-rewrite.test.ts-t "does not send credentials" — 1 passed
vp checkclean on all four changed source files (format, lint, types).
Notes (non-blocking)
- The two new coverage cases correctly capture both override-list states (no-list = forward, absent-from-list = drop), which is exactly the boundary #1121 got wrong. Good.
- Fixture change reuses the existing
/middleware-external-rewritecase cleanly;Headerslowercasing meansdelete("cookie")/delete("authorization")correctly strip the incomingCookie/Authorization.
No issues found. Approve.
|
Review posted successfully. Summary of my findings: Review outcome: Approved — no issues foundThis is a correct, well-reasoned security fix. I verified the central claim against Next.js source and confirmed the implementation. What the PR does: Removes the Verification I performed:
The review was submitted as an APPROVE with detailed reasoning inline on PR #2739. |
The decision boundary
x-middleware-override-headersis the complete post-middleware header set, not a diff.NextResponse.next/rewriteencode every key of theHeadersobject middleware passes (response.ts—handleMiddlewareField).Next.js then deletes any request header not in that list (
resolve-routes.ts):There is no tombstone for a deleted header because none is needed. Absence is the tombstone.
The bug
preserveCredentialHeaders(#1121) read a short override list as a partial override and copied the base request'scookie/authorizationback in. But the documented deletion pattern produces exactly such a list:cookieis simply absent from the override list — so the option resurrected it. The option was enabled only for external rewrites, soproxyExternalRequestthen forwarded first-party session cookies and bearer tokens to a cross-origin target, despite the app explicitly stripping them.Same-origin restoration would have been merely a parity bug; gating it to cross-origin destinations turned it into a credential leak.
Why removal, not a narrower guard
The "partial override" the option guarded against cannot occur.
encodeMiddlewareRequestHeadersis the only producer of the override list in the codebase, and it always emits the full key set:#1121's own "explicit deletion" test passed a list naming
authorization,cookiewith no correspondingx-middleware-request-*values — an encoding the public API never emits — so it never exercised the real deletion path. Deleting the option restores Next.js-exact semantics and removes the parameter from three call sites.Scenario-level behavior
NextResponse.next()(norequest)cookiecookiecookiecookierestored and sent cross-origincookiedroppedOnly the third row changes.
Validation
Both regression tests were confirmed failing before the fix, passing after:
tests/app-router-external-rewrite.test.ts— end to end through the dev server; fixture middleware deletescookie/authorizationbefore an external rewrite, mock upstream asserts neither arrives. Pre-fix:expected 'session=secret123' to be undefined.tests/shims.test.ts— theproxyExternalMiddlewareRewriteboundary, driven through the realNextResponse.rewriteAPI against a livenode:httpupstream. Pre-fix:expected 'Bearer secret' to be undefined.tests/pages-request-pipeline.test.ts— Pages Router pipeline, both the no-override-list (credentials forwarded) and deleted-from-override-list (dropped) cases.tests/request-pipeline.test.ts— unit coverage for both override-list states, replacing the two fix(middleware): preserve credentials for external override rewrites #1121 tests that encoded the buggy expectation.pnpm run checkpasses (format, lint, types, Next.js type sync, shim types).Risk / review path
Behavior change is confined to the one row above — restoring a header the app deleted. Any app relying on the old behavior was relying on a credential leak.
Suggested review order:
middleware-request-headers.ts(the semantics), then the three call sites (mechanical parameter removal), then the tests.