From 61c9c851520da04679cbd552e2369e1ec0a658ee Mon Sep 17 00:00:00 2001 From: Enrico Ort Date: Mon, 3 Aug 2026 21:52:54 +0200 Subject: [PATCH 1/3] Preserve the original host on forwarded Server Action requests When an action POST lands on a route that does not bundle the action, it is forwarded to a worker that does, via an internal self-fetch to `__NEXT_PRIVATE_ORIGIN`. `host` is a forbidden `fetch` header, so it cannot be carried onto the subrequest: the action saw `headers().get('host')` as `localhost:PORT` instead of the host the user requested. That breaks host-based multi-tenancy for exactly those actions that happen to get forwarded, which depends on the build's static import graph rather than anything visible in the application. Restore `host` from `x-forwarded-host`, which does survive the forward, guarded on the request being marked `x-action-forwarded` and having arrived at the internal origin. `x-forwarded-host` is already preferred over `host` by `parseHostHeader` for the CSRF origin check, so this aligns userland `headers()` with the value the framework already trusts. Fixes #96344 --- .../server/app-render/action-forwarding.ts | 64 +++++++++++ .../src/server/app-render/action-handler.ts | 14 +-- packages/next/src/server/base-server.ts | 3 + .../action-forward-host.test.ts | 105 ++++++++++++++++++ .../action-forward-host/app/layout.tsx | 8 ++ .../app/with-action/actions.ts | 19 ++++ .../app/with-action/page.tsx | 15 +++ .../app/without-action/page.tsx | 5 + .../action-forward-host/next.config.js | 6 + 9 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 packages/next/src/server/app-render/action-forwarding.ts create mode 100644 test/e2e/app-dir/action-forward-host/action-forward-host.test.ts create mode 100644 test/e2e/app-dir/action-forward-host/app/layout.tsx create mode 100644 test/e2e/app-dir/action-forward-host/app/with-action/actions.ts create mode 100644 test/e2e/app-dir/action-forward-host/app/with-action/page.tsx create mode 100644 test/e2e/app-dir/action-forward-host/app/without-action/page.tsx create mode 100644 test/e2e/app-dir/action-forward-host/next.config.js diff --git a/packages/next/src/server/app-render/action-forwarding.ts b/packages/next/src/server/app-render/action-forwarding.ts new file mode 100644 index 000000000000..9fec8a6cf163 --- /dev/null +++ b/packages/next/src/server/app-render/action-forwarding.ts @@ -0,0 +1,64 @@ +import type { IncomingHttpHeaders } from 'http' + +/** + * Set by `createForwardedActionResponse` on a Server Action request that it has + * forwarded to a different worker, so the receiving worker knows the request has + * already been forwarded once. + */ +export const ACTION_FORWARDED_HEADER = 'x-action-forwarded' + +/** + * Reads the first value of `x-forwarded-host`, which can arrive either as a + * repeated header or as a single comma-separated list. + */ +export function getForwardedHostValue( + headers: IncomingHttpHeaders +): string | undefined { + const forwardedHostHeader = headers['x-forwarded-host'] + + return Array.isArray(forwardedHostHeader) + ? forwardedHostHeader[0] + : forwardedHostHeader?.split(',')?.[0]?.trim() +} + +/** + * A Server Action POST that lands on a route which doesn't bundle the action is + * forwarded to a worker that does, by fetching our own internal origin (see + * `createForwardedActionResponse`). `host` is a forbidden `fetch` header, so it + * can't be carried over, and the subrequest arrives claiming to be for the + * internal origin instead of the host the user actually requested. + * + * `x-forwarded-host` does survive the forward, so restore `host` from it. + * Otherwise `headers().get('host')` inside a forwarded action reports + * `localhost:PORT`, which silently breaks host-based multi-tenancy for exactly + * those actions that happen to get forwarded. + */ +export function restoreForwardedActionHost(headers: IncomingHttpHeaders): void { + if (!headers[ACTION_FORWARDED_HEADER]) { + return + } + + // Only rewrite a request that really did arrive over the internal forward. + // When the origin isn't set, `createForwardedActionResponse` falls back to the + // initial request URL, which already carries the original host. + const internalOrigin = process.env.__NEXT_PRIVATE_ORIGIN + if (!internalOrigin) { + return + } + + let internalHost: string + try { + internalHost = new URL(internalOrigin).host + } catch { + return + } + + if (headers['host'] !== internalHost) { + return + } + + const forwardedHost = getForwardedHostValue(headers) + if (forwardedHost) { + headers['host'] = forwardedHost + } +} diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 1156f88da65e..1a655a021229 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -48,6 +48,10 @@ import { import { getServerActionRequestMetadata } from '../lib/server-action-request-meta' import { isCsrfOriginAllowed } from './csrf-protection' import { warn } from '../../build/output/log' +import { + ACTION_FORWARDED_HEADER, + getForwardedHostValue, +} from './action-forwarding' import { RequestCookies, ResponseCookies } from '../web/spec-extension/cookies' import { HeadersAdapter } from '../web/spec-extension/adapters/headers' import { fromNodeOutgoingHttpHeaders } from '../web/utils' @@ -225,7 +229,7 @@ async function createForwardedActionResponse( // indicate that this action request was forwarded from another worker // we use this to skip rendering the flight tree so that we don't update the UI // with the response from the forwarded worker - forwardedHeaders.set('x-action-forwarded', '1') + forwardedHeaders.set(ACTION_FORWARDED_HEADER, '1') // TODO: Remove __NEXT_PRIVATE_ORIGIN let origin: string | undefined = process.env.__NEXT_PRIVATE_ORIGIN @@ -512,11 +516,7 @@ export function parseHostHeader( headers: IncomingHttpHeaders, originDomain?: string ) { - const forwardedHostHeader = headers['x-forwarded-host'] - const forwardedHostHeaderValue = - forwardedHostHeader && Array.isArray(forwardedHostHeader) - ? forwardedHostHeader[0] - : forwardedHostHeader?.split(',')?.[0]?.trim() + const forwardedHostHeaderValue = getForwardedHostValue(headers) const hostHeader = headers['host'] if (originDomain) { @@ -754,7 +754,7 @@ export async function handleAction({ 'no-cache, no-store, max-age=0, must-revalidate' ) - const actionWasForwarded = Boolean(req.headers['x-action-forwarded']) + const actionWasForwarded = Boolean(req.headers[ACTION_FORWARDED_HEADER]) // A fetch action targeting a fallback route has no concrete params with // which to resume the destination page. const isActionOnlyFallbackRequest = diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 9e0a5545dda6..fcc1e8b54ed2 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -130,6 +130,7 @@ import { matchNextDataPathname } from './lib/match-next-data-pathname' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' import { RSCPathnameNormalizer } from './normalizers/request/rsc' import { stripFlightHeaders } from './app-render/strip-flight-headers' +import { restoreForwardedActionHost } from './app-render/action-forwarding' import { isAppPageRouteModule, isAppRouteRouteModule, @@ -1089,6 +1090,8 @@ export default abstract class Server< req.headers['x-forwarded-proto'] ??= isHttps ? 'https' : 'http' req.headers['x-forwarded-for'] ??= originalRequest?.socket?.remoteAddress + restoreForwardedActionHost(req.headers) + // This should be done before any normalization of the pathname happens as // it captures the initial URL. this.attachRequestMeta(req, parsedUrl) diff --git a/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts new file mode 100644 index 000000000000..f9d7c8b57b75 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts @@ -0,0 +1,105 @@ +import { nextTestSetup } from 'e2e-utils' +import http from 'http' + +// A host that is deliberately not the origin the server listens on, so that a +// forwarded action's `headers().get('host')` is distinguishable from the +// internal loopback origin. +const HOST = 'example.test' + +type ObservedHeaders = { + host: string | null + xForwardedHost: string | null + actionForwarded: string | null +} + +describe('server action forwarding - original host', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + // `next.fetch` goes through `undici`, which refuses to set `host` because it + // is a forbidden header. Use the raw http client so we can send an arbitrary + // `Host`, the way a reverse proxy in front of `next start` would. + function postAction( + pathname: string, + actionId: string + ): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + const request = http.request( + { + hostname: '127.0.0.1', + port: next.appPort, + path: pathname, + method: 'POST', + headers: { + host: HOST, + origin: `http://${HOST}`, + 'content-type': 'text/plain;charset=UTF-8', + 'next-action': actionId, + }, + }, + (response) => { + response.resume() + response.on('end', () => resolve({ status: response.statusCode! })) + response.on('error', reject) + } + ) + + request.on('error', reject) + request.end('[]') + }) + } + + // Read the action id out of the rendered form rather than the build manifest, + // so this works the same in dev and in start. + async function getActionId(): Promise { + const html = await next.render('/with-action') + const match = html.match(/\$ACTION_ID_([0-9a-f]{42})/) + + if (!match) { + throw new Error(`Could not find an action id in:\n${html}`) + } + + return match[1] + } + + async function collectObservedHeaders( + pathname: string + ): Promise { + const actionId = await getActionId() + const outputIndex = next.cliOutput.length + + const { status } = await postAction(pathname, actionId) + expect(status).toBe(200) + + const output = next.cliOutput.slice(outputIndex) + const match = output.match(/\[reportHost\](\{.*\})/) + + if (!match) { + throw new Error(`The action did not run. Server output was:\n${output}`) + } + + return JSON.parse(match[1]) + } + + it('observes the request host when the action is not forwarded', async () => { + const observed = await collectObservedHeaders('/with-action') + + // Sanity check: this route bundles the action, so nothing was forwarded. + expect(observed.actionForwarded).toBeNull() + expect(observed.host).toBe(HOST) + }) + + it('observes the request host when the action is forwarded to another worker', async () => { + const observed = await collectObservedHeaders('/without-action') + + // Sanity check: this route does not bundle the action, so the request + // really did go through the forwarding path. + expect(observed.actionForwarded).toBe('1') + expect(observed.xForwardedHost).toBe(HOST) + + // The forward is an internal self-fetch to the loopback origin, which + // replaces `Host`. The original host must survive it. + expect(observed.host).toBe(HOST) + }) +}) diff --git a/test/e2e/app-dir/action-forward-host/app/layout.tsx b/test/e2e/app-dir/action-forward-host/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/action-forward-host/app/with-action/actions.ts b/test/e2e/app-dir/action-forward-host/app/with-action/actions.ts new file mode 100644 index 000000000000..daa62f2e5929 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/with-action/actions.ts @@ -0,0 +1,19 @@ +'use server' + +import { headers } from 'next/headers' + +// Logged to the terminal so the test can assert on what the action actually +// observed. The test sends a `Host` that differs from the server's own origin, +// which a browser cannot do, so this is the only way to see the difference. +export async function reportHost() { + const headerList = await headers() + + console.log( + `[reportHost]` + + JSON.stringify({ + host: headerList.get('host'), + xForwardedHost: headerList.get('x-forwarded-host'), + actionForwarded: headerList.get('x-action-forwarded'), + }) + ) +} diff --git a/test/e2e/app-dir/action-forward-host/app/with-action/page.tsx b/test/e2e/app-dir/action-forward-host/app/with-action/page.tsx new file mode 100644 index 000000000000..c3a7eedc23ae --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/with-action/page.tsx @@ -0,0 +1,15 @@ +import { reportHost } from './actions' + +// This page imports the action, so it is the only entry in the action's +// `workers` set. A POST carrying this action's id to any other route has to be +// forwarded here. +export default function Page() { + return ( +
+

with-action

+
+ +
+
+ ) +} diff --git a/test/e2e/app-dir/action-forward-host/app/without-action/page.tsx b/test/e2e/app-dir/action-forward-host/app/without-action/page.tsx new file mode 100644 index 000000000000..f181cc59a296 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/without-action/page.tsx @@ -0,0 +1,5 @@ +// Deliberately does not import the action, so an action POST that lands here +// goes through `createForwardedActionResponse`. +export default function Page() { + return
without-action
+} diff --git a/test/e2e/app-dir/action-forward-host/next.config.js b/test/e2e/app-dir/action-forward-host/next.config.js new file mode 100644 index 000000000000..807126e4cf0b --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig From f3632590331defe4d9654eb8f92688c76bc56450 Mon Sep 17 00:00:00 2001 From: Enrico Ort Date: Fri, 7 Aug 2026 22:34:52 +0200 Subject: [PATCH 2/3] fix: preserve the original host on forwarded Server Action requests --- .../app-render/action-forwarding.test.ts | 267 ++++++++++++++++++ .../server/app-render/action-forwarding.ts | 103 ++++++- .../src/server/app-render/action-handler.ts | 43 +-- packages/next/src/server/base-server.ts | 13 +- .../action-forward-host.test.ts | 39 ++- 5 files changed, 406 insertions(+), 59 deletions(-) create mode 100644 packages/next/src/server/app-render/action-forwarding.test.ts diff --git a/packages/next/src/server/app-render/action-forwarding.test.ts b/packages/next/src/server/app-render/action-forwarding.test.ts new file mode 100644 index 000000000000..1ae49f7d9be7 --- /dev/null +++ b/packages/next/src/server/app-render/action-forwarding.test.ts @@ -0,0 +1,267 @@ +import type { IncomingHttpHeaders } from 'http' +import type { BaseNextRequest } from '../base-http' +import { NEXT_REQUEST_META } from '../request-meta' +import { + getActionForwardingOrigin, + getForwardedHostValue, + restoreForwardedActionHost, +} from './action-forwarding' + +const ACTION_ID = '00' + 'a'.repeat(40) +const PRIVATE_ORIGIN = 'http://localhost:3000' +const INTERNAL_HOST = 'localhost:3000' +const PUBLIC_HOST = 'tenant.example' + +function createRequest({ + headers, + method = 'POST', + initURL, +}: { + headers?: IncomingHttpHeaders + method?: string + initURL?: string +}): BaseNextRequest { + return { + method, + headers: { + 'next-action': ACTION_ID, + 'content-type': 'text/plain;charset=UTF-8', + ...headers, + }, + [NEXT_REQUEST_META]: { initURL }, + } as unknown as BaseNextRequest +} + +// The shape `createForwardedActionResponse` produces: it arrives at the origin +// we forwarded to, and only `x-forwarded-host` still knows the original host. +function createForwardedRequest( + headers?: IncomingHttpHeaders, + initURL?: string +): BaseNextRequest { + return createRequest({ + headers: { + host: INTERNAL_HOST, + 'x-forwarded-host': PUBLIC_HOST, + 'x-action-forwarded': '1', + ...headers, + }, + initURL, + }) +} + +describe('getForwardedHostValue', () => { + it('reads a single value', () => { + expect(getForwardedHostValue({ 'x-forwarded-host': PUBLIC_HOST })).toBe( + PUBLIC_HOST + ) + }) + + it('reads the first value of a comma-separated list', () => { + expect( + getForwardedHostValue({ + 'x-forwarded-host': `${PUBLIC_HOST}, proxy.example`, + }) + ).toBe(PUBLIC_HOST) + }) + + it('reads the first value of a repeated header', () => { + expect( + getForwardedHostValue({ + 'x-forwarded-host': [PUBLIC_HOST, 'proxy.example'], + }) + ).toBe(PUBLIC_HOST) + }) + + it('returns undefined when the header is missing or empty', () => { + expect(getForwardedHostValue({})).toBeUndefined() + expect(getForwardedHostValue({ 'x-forwarded-host': [] })).toBeUndefined() + }) +}) + +describe('getActionForwardingOrigin', () => { + const originalPrivateOrigin = process.env.__NEXT_PRIVATE_ORIGIN + + afterEach(() => { + process.env.__NEXT_PRIVATE_ORIGIN = originalPrivateOrigin + }) + + it('prefers the private origin', () => { + process.env.__NEXT_PRIVATE_ORIGIN = PRIVATE_ORIGIN + + expect( + getActionForwardingOrigin( + createRequest({ initURL: 'http://127.0.0.1:4000/some/path' }) + ) + ).toBe(PRIVATE_ORIGIN) + }) + + it('falls back to the origin of initURL', () => { + delete process.env.__NEXT_PRIVATE_ORIGIN + + expect( + getActionForwardingOrigin( + createRequest({ initURL: 'http://localhost:3000/some/path?a=b' }) + ) + ).toBe(PRIVATE_ORIGIN) + }) + + it('throws when initURL is missing', () => { + delete process.env.__NEXT_PRIVATE_ORIGIN + + expect(() => getActionForwardingOrigin(createRequest({}))).toThrow( + 'Missing initURL' + ) + }) + + it('throws when initURL is not absolute', () => { + delete process.env.__NEXT_PRIVATE_ORIGIN + + // What `attachRequestMeta` produces when the server has no configured + // hostname and port and does not trust the host header. + expect(() => + getActionForwardingOrigin(createRequest({ initURL: '/some/path' })) + ).toThrow('Could not determine origin') + }) +}) + +describe('restoreForwardedActionHost', () => { + const originalPrivateOrigin = process.env.__NEXT_PRIVATE_ORIGIN + + beforeEach(() => { + process.env.__NEXT_PRIVATE_ORIGIN = PRIVATE_ORIGIN + }) + + afterEach(() => { + process.env.__NEXT_PRIVATE_ORIGIN = originalPrivateOrigin + }) + + function restore(req: BaseNextRequest, hasConfiguredOrigin = true) { + restoreForwardedActionHost(req, { hasConfiguredOrigin }) + return req.headers['host'] + } + + it('restores the original host on a forwarded action', () => { + expect(restore(createForwardedRequest())).toBe(PUBLIC_HOST) + }) + + it('restores the first value of an x-forwarded-host list', () => { + expect( + restore( + createForwardedRequest({ + 'x-forwarded-host': `${PUBLIC_HOST}, proxy.example`, + }) + ) + ).toBe(PUBLIC_HOST) + + expect( + restore( + createForwardedRequest({ + 'x-forwarded-host': [PUBLIC_HOST, 'proxy.example'], + }) + ) + ).toBe(PUBLIC_HOST) + }) + + it('restores a host that carries an explicit port', () => { + expect( + restore( + createForwardedRequest({ 'x-forwarded-host': `${PUBLIC_HOST}:8443` }) + ) + ).toBe(`${PUBLIC_HOST}:8443`) + }) + + it('compares the internal origin with its port', () => { + // Same hostname, different port: this request did not arrive at the origin + // we forward to. + expect(restore(createForwardedRequest({ host: 'localhost:3001' }))).toBe( + 'localhost:3001' + ) + }) + + it('ignores a request that did not arrive at the internal origin', () => { + // A client that forges the marker but reaches the server on its public + // host must not be able to rewrite `host` from a header it also controls. + expect(restore(createForwardedRequest({ host: 'attacker.example' }))).toBe( + 'attacker.example' + ) + }) + + it.each([undefined, '', 'true', '0', '1, 1', 'yes'])( + 'ignores the marker value %p', + (markerValue) => { + expect( + restore(createForwardedRequest({ 'x-action-forwarded': markerValue })) + ).toBe(INTERNAL_HOST) + } + ) + + it('ignores a request that is not a fetch action', () => { + // Only a POST carrying an action id is ever forwarded. + expect( + restore( + createRequest({ + method: 'GET', + headers: { + host: INTERNAL_HOST, + 'x-forwarded-host': PUBLIC_HOST, + 'x-action-forwarded': '1', + }, + }) + ) + ).toBe(INTERNAL_HOST) + + expect( + restore( + createForwardedRequest({ + // A multipart (MPA) action is never forwarded either. + 'next-action': undefined, + 'content-type': 'multipart/form-data; boundary=----x', + }) + ) + ).toBe(INTERNAL_HOST) + }) + + it('ignores a request without x-forwarded-host', () => { + expect( + restore(createForwardedRequest({ 'x-forwarded-host': undefined })) + ).toBe(INTERNAL_HOST) + }) + + it('ignores a malformed private origin', () => { + process.env.__NEXT_PRIVATE_ORIGIN = 'not a url' + + expect(restore(createForwardedRequest())).toBe(INTERNAL_HOST) + }) + + describe('without a private origin', () => { + beforeEach(() => { + delete process.env.__NEXT_PRIVATE_ORIGIN + }) + + it('compares against the origin of initURL', () => { + expect( + restore( + createForwardedRequest({}, 'http://localhost:3000/without-action') + ) + ).toBe(PUBLIC_HOST) + }) + + it('ignores initURL when the server has no configured origin', () => { + // `initURL` is then built from this request's own host header, so it + // proves nothing about where the request arrived. + expect( + restore( + createForwardedRequest({}, `http://${INTERNAL_HOST}/without-action`), + false + ) + ).toBe(INTERNAL_HOST) + }) + + it('ignores a request when no origin can be determined', () => { + expect(restore(createForwardedRequest())).toBe(INTERNAL_HOST) + expect(restore(createForwardedRequest({}, '/without-action'))).toBe( + INTERNAL_HOST + ) + }) + }) +}) diff --git a/packages/next/src/server/app-render/action-forwarding.ts b/packages/next/src/server/app-render/action-forwarding.ts index 9fec8a6cf163..0c9b4454174d 100644 --- a/packages/next/src/server/app-render/action-forwarding.ts +++ b/packages/next/src/server/app-render/action-forwarding.ts @@ -1,4 +1,8 @@ import type { IncomingHttpHeaders } from 'http' +import type { BaseNextRequest } from '../base-http' +import { getRequestMeta } from '../request-meta' +import { getServerActionRequestMetadata } from '../lib/server-action-request-meta' +import { InvariantError } from '../../shared/lib/invariant-error' /** * Set by `createForwardedActionResponse` on a Server Action request that it has @@ -7,6 +11,14 @@ import type { IncomingHttpHeaders } from 'http' */ export const ACTION_FORWARDED_HEADER = 'x-action-forwarded' +/** + * The only value `createForwardedActionResponse` ever sends. The header is not + * in `INTERNAL_HEADERS` (it can't be — the same ingress filter runs on the + * loopback request and would strip the genuine marker), so an external client + * can send it too. Matching the value exactly keeps the checks below narrow. + */ +export const ACTION_FORWARDED_VALUE = '1' + /** * Reads the first value of `x-forwarded-host`, which can arrive either as a * repeated header or as a single comma-separated list. @@ -21,44 +33,105 @@ export function getForwardedHostValue( : forwardedHostHeader?.split(',')?.[0]?.trim() } +/** + * The origin Next.js fetches when it forwards a request to itself: action + * forwarding (`createForwardedActionResponse`) and app-relative redirect + * streaming (`createRedirectRenderResult`) both go here, and + * `restoreForwardedActionHost` uses it to recognize a request that arrived over + * such a forward. The send and receive sides have to agree, so they share this. + * + * Throws when no origin can be determined, which is a hard error on the send + * side — there is nowhere to forward to. + */ +export function getActionForwardingOrigin(req: BaseNextRequest): string { + // TODO: Remove __NEXT_PRIVATE_ORIGIN + const privateOrigin = process.env.__NEXT_PRIVATE_ORIGIN + if (privateOrigin !== undefined) { + return privateOrigin + } + + const initURL = getRequestMeta(req, 'initURL') + if (initURL === undefined) { + throw new InvariantError('Missing initURL') + } + + try { + return new URL(initURL).origin + } catch (error) { + throw new Error( + 'Could not determine origin for forwarded Server Actions request. This can happen if port or hostname are not configured for this server.', + { cause: error } + ) + } +} + /** * A Server Action POST that lands on a route which doesn't bundle the action is - * forwarded to a worker that does, by fetching our own internal origin (see + * forwarded to a worker that does, by fetching our own forwarding origin (see * `createForwardedActionResponse`). `host` is a forbidden `fetch` header, so it - * can't be carried over, and the subrequest arrives claiming to be for the + * can't be carried over, and the subrequest arrives claiming to be for that * internal origin instead of the host the user actually requested. * * `x-forwarded-host` does survive the forward, so restore `host` from it. * Otherwise `headers().get('host')` inside a forwarded action reports * `localhost:PORT`, which silently breaks host-based multi-tenancy for exactly * those actions that happen to get forwarded. + * + * `x-action-forwarded` is not an authenticated marker, so it is treated as a + * hint rather than as proof: the rewrite additionally requires the request to + * be a fetch action (the only shape that is ever forwarded) that arrived at the + * origin we would have forwarded to. */ -export function restoreForwardedActionHost(headers: IncomingHttpHeaders): void { - if (!headers[ACTION_FORWARDED_HEADER]) { +export function restoreForwardedActionHost( + req: BaseNextRequest, + { + hasConfiguredOrigin, + }: { + /** + * Whether the server was started with an explicit hostname and port, and so + * builds `initURL` from its own origin rather than from the incoming `host` + * header. See `attachRequestMeta` in `next-server.ts`. + */ + hasConfiguredOrigin: boolean + } +): void { + // Not a truthiness check: the header is forgeable, so only the exact value we + // send counts. A repeated header arrives here comma-joined, which also fails. + if (req.headers[ACTION_FORWARDED_HEADER] !== ACTION_FORWARDED_VALUE) { return } - // Only rewrite a request that really did arrive over the internal forward. - // When the origin isn't set, `createForwardedActionResponse` falls back to the - // initial request URL, which already carries the original host. - const internalOrigin = process.env.__NEXT_PRIVATE_ORIGIN - if (!internalOrigin) { + // `handleAction` only forwards when it has an action id on a POST, so no + // other request shape can have reached us through the forwarding path. + if (!getServerActionRequestMetadata(req).isFetchAction) { + return + } + + const forwardedHost = getForwardedHostValue(req.headers) + if (!forwardedHost) { + return + } + + // Without a private origin the forwarding origin comes from `initURL`, which + // is the server's own origin only when it was started with a hostname and + // port. Otherwise `initURL` is built from this request's own `host` header, + // which would make the origin comparison below vacuously true. + if (process.env.__NEXT_PRIVATE_ORIGIN === undefined && !hasConfiguredOrigin) { return } let internalHost: string try { - internalHost = new URL(internalOrigin).host + internalHost = new URL(getActionForwardingOrigin(req)).host } catch { + // We can't tell where a forward would have gone, so don't rewrite anything. return } - if (headers['host'] !== internalHost) { + // The request has to have arrived at the origin we forward to. + if (req.headers['host'] !== internalHost) { return } - const forwardedHost = getForwardedHostValue(headers) - if (forwardedHost) { - headers['host'] = forwardedHost - } + req.headers['host'] = forwardedHost } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 1a655a021229..4c4518e93915 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -50,6 +50,8 @@ import { isCsrfOriginAllowed } from './csrf-protection' import { warn } from '../../build/output/log' import { ACTION_FORWARDED_HEADER, + ACTION_FORWARDED_VALUE, + getActionForwardingOrigin, getForwardedHostValue, } from './action-forwarding' import { RequestCookies, ResponseCookies } from '../web/spec-extension/cookies' @@ -229,26 +231,9 @@ async function createForwardedActionResponse( // indicate that this action request was forwarded from another worker // we use this to skip rendering the flight tree so that we don't update the UI // with the response from the forwarded worker - forwardedHeaders.set(ACTION_FORWARDED_HEADER, '1') + forwardedHeaders.set(ACTION_FORWARDED_HEADER, ACTION_FORWARDED_VALUE) - // TODO: Remove __NEXT_PRIVATE_ORIGIN - let origin: string | undefined = process.env.__NEXT_PRIVATE_ORIGIN - if (origin === undefined) { - const initUrl = getRequestMeta(req, 'initURL') - if (initUrl !== undefined) { - try { - const parsedUrl = new URL(initUrl) - origin = parsedUrl.origin - } catch (error) { - throw new Error( - 'Could not determine origin for forwarded Server Actions request. This can happen if port or hostname are not configured for this server.', - { cause: error } - ) - } - } else { - throw new InvariantError('Missing initURL') - } - } + const origin = getActionForwardingOrigin(req) const fetchUrl = new URL(`${origin}${basePath}${workerPathname}`) @@ -401,25 +386,7 @@ async function createRedirectRenderResult( const forwardedHeaders = getForwardedHeaders(req, res) forwardedHeaders.set(RSC_HEADER, '1') - // TODO: Remove __NEXT_PRIVATE_ORIGIN - let origin: string | undefined = process.env.__NEXT_PRIVATE_ORIGIN - if (origin === undefined) { - const initUrl = getRequestMeta(req, 'initURL') - if (initUrl !== undefined) { - try { - const parsedUrl = new URL(initUrl) - - origin = parsedUrl.origin - } catch (error) { - throw new Error( - 'Could not determine origin for forwarded Server Actions request. This can happen if port or hostname are not configured for this server.', - { cause: error } - ) - } - } else { - throw new InvariantError('Missing initURL') - } - } + const origin = getActionForwardingOrigin(req) const fetchUrl = new URL( `${origin}${appRelativeRedirectUrl.pathname}${appRelativeRedirectUrl.search}` diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index fcc1e8b54ed2..c5f593e15a96 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1090,12 +1090,21 @@ export default abstract class Server< req.headers['x-forwarded-proto'] ??= isHttps ? 'https' : 'http' req.headers['x-forwarded-for'] ??= originalRequest?.socket?.remoteAddress - restoreForwardedActionHost(req.headers) - // This should be done before any normalization of the pathname happens as // it captures the initial URL. this.attachRequestMeta(req, parsedUrl) + // A Server Action that we forwarded to another worker arrives over an + // internal self-fetch, which replaces `host` with the origin we forwarded + // to. This runs after `attachRequestMeta`, because the forwarding origin + // can come from `initURL`, and before the first consumers of `host`: + // domain locale detection below, and later `headers()` inside the action. + restoreForwardedActionHost(req, { + // Mirrors the condition `attachRequestMeta` uses to build `initURL` + // from this server's own hostname and port. + hasConfiguredOrigin: Boolean(this.fetchHostname && this.port), + }) + let finished = await this.handleRSCRequest(req, res, parsedUrl) if (finished) return diff --git a/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts index f9d7c8b57b75..f8f7dca9f92a 100644 --- a/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts +++ b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts @@ -6,6 +6,9 @@ import http from 'http' // internal loopback origin. const HOST = 'example.test' +// The host a client would try to smuggle in through `x-forwarded-host`. +const FORGED_HOST = 'forged.test' + type ObservedHeaders = { host: string | null xForwardedHost: string | null @@ -13,16 +16,27 @@ type ObservedHeaders = { } describe('server action forwarding - original host', () => { - const { next } = nextTestSetup({ + // The behaviour under test only exists on a self-hosted server: the action is + // forwarded over a private loopback fetch to the server's own origin. The + // test needs a local port to connect to, has to send a `Host` that isn't the + // one it connected to, and reads the action's output from the running + // server's stdout — none of which a deployment gives us. + const { next, skipped } = nextTestSetup({ files: __dirname, + skipDeployment: true, }) + if (skipped) { + return + } + // `next.fetch` goes through `undici`, which refuses to set `host` because it // is a forbidden header. Use the raw http client so we can send an arbitrary // `Host`, the way a reverse proxy in front of `next start` would. function postAction( pathname: string, - actionId: string + actionId: string, + extraHeaders?: Record ): Promise<{ status: number }> { return new Promise((resolve, reject) => { const request = http.request( @@ -36,6 +50,7 @@ describe('server action forwarding - original host', () => { origin: `http://${HOST}`, 'content-type': 'text/plain;charset=UTF-8', 'next-action': actionId, + ...extraHeaders, }, }, (response) => { @@ -64,12 +79,13 @@ describe('server action forwarding - original host', () => { } async function collectObservedHeaders( - pathname: string + pathname: string, + extraHeaders?: Record ): Promise { const actionId = await getActionId() const outputIndex = next.cliOutput.length - const { status } = await postAction(pathname, actionId) + const { status } = await postAction(pathname, actionId, extraHeaders) expect(status).toBe(200) const output = next.cliOutput.slice(outputIndex) @@ -102,4 +118,19 @@ describe('server action forwarding - original host', () => { // replaces `Host`. The original host must survive it. expect(observed.host).toBe(HOST) }) + + it('ignores a forged forwarding marker', async () => { + // `x-action-forwarded` is not an internal header, so a client can send it. + // This request arrives on the public host rather than the origin we forward + // to, so the marker must not turn `x-forwarded-host` into `host`. `origin` + // matches the forged `x-forwarded-host` only to get past the CSRF check. + const observed = await collectObservedHeaders('/with-action', { + 'x-action-forwarded': '1', + 'x-forwarded-host': FORGED_HOST, + origin: `http://${FORGED_HOST}`, + }) + + expect(observed.xForwardedHost).toBe(FORGED_HOST) + expect(observed.host).toBe(HOST) + }) }) From 7b823863140188e767a9ea836bb6f0143bd6c0d4 Mon Sep 17 00:00:00 2001 From: David Ilie Date: Sat, 8 Aug 2026 13:03:56 +0100 Subject: [PATCH 3/3] Preserve the original host on action redirect streams --- .../app-render/action-forwarding.test.ts | 54 +++++++++++- .../server/app-render/action-forwarding.ts | 54 +++++++----- .../src/server/app-render/action-handler.ts | 6 ++ packages/next/src/server/base-server.ts | 14 ++-- .../action-forward-host.test.ts | 83 +++++++++++++++++-- .../app/redirect-action/actions.ts | 7 ++ .../app/redirect-action/page.tsx | 9 ++ .../app/redirect-target/page.tsx | 27 ++++++ .../app/without-redirect-action/page.tsx | 5 ++ 9 files changed, 223 insertions(+), 36 deletions(-) create mode 100644 test/e2e/app-dir/action-forward-host/app/redirect-action/actions.ts create mode 100644 test/e2e/app-dir/action-forward-host/app/redirect-action/page.tsx create mode 100644 test/e2e/app-dir/action-forward-host/app/redirect-target/page.tsx create mode 100644 test/e2e/app-dir/action-forward-host/app/without-redirect-action/page.tsx diff --git a/packages/next/src/server/app-render/action-forwarding.test.ts b/packages/next/src/server/app-render/action-forwarding.test.ts index 1ae49f7d9be7..1557bf8b491e 100644 --- a/packages/next/src/server/app-render/action-forwarding.test.ts +++ b/packages/next/src/server/app-render/action-forwarding.test.ts @@ -4,7 +4,7 @@ import { NEXT_REQUEST_META } from '../request-meta' import { getActionForwardingOrigin, getForwardedHostValue, - restoreForwardedActionHost, + restoreActionForwardingHost, } from './action-forwarding' const ACTION_ID = '00' + 'a'.repeat(40) @@ -49,6 +49,23 @@ function createForwardedRequest( }) } +function createRedirectRequest( + headers?: IncomingHttpHeaders, + method = 'GET' +): BaseNextRequest { + return createRequest({ + method, + headers: { + host: INTERNAL_HOST, + 'x-forwarded-host': PUBLIC_HOST, + 'x-action-redirect-forwarded': '1', + rsc: '1', + 'next-action': undefined, + ...headers, + }, + }) +} + describe('getForwardedHostValue', () => { it('reads a single value', () => { expect(getForwardedHostValue({ 'x-forwarded-host': PUBLIC_HOST })).toBe( @@ -124,7 +141,7 @@ describe('getActionForwardingOrigin', () => { }) }) -describe('restoreForwardedActionHost', () => { +describe('restoreActionForwardingHost', () => { const originalPrivateOrigin = process.env.__NEXT_PRIVATE_ORIGIN beforeEach(() => { @@ -136,7 +153,7 @@ describe('restoreForwardedActionHost', () => { }) function restore(req: BaseNextRequest, hasConfiguredOrigin = true) { - restoreForwardedActionHost(req, { hasConfiguredOrigin }) + restoreActionForwardingHost(req, { hasConfiguredOrigin }) return req.headers['host'] } @@ -144,6 +161,37 @@ describe('restoreForwardedActionHost', () => { expect(restore(createForwardedRequest())).toBe(PUBLIC_HOST) }) + it('restores the original host on a streamed action redirect', () => { + expect(restore(createRedirectRequest())).toBe(PUBLIC_HOST) + }) + + it.each([undefined, '', 'true', '0', '1, 1', 'yes'])( + 'ignores the redirect marker value %p', + (markerValue) => { + expect( + restore( + createRedirectRequest({ + 'x-action-redirect-forwarded': markerValue, + }) + ) + ).toBe(INTERNAL_HOST) + } + ) + + it('ignores a redirect marker without the internal RSC request shape', () => { + expect(restore(createRedirectRequest({ rsc: undefined }))).toBe( + INTERNAL_HOST + ) + + expect(restore(createRedirectRequest({}, 'POST'))).toBe(INTERNAL_HOST) + }) + + it('ignores a redirect request that did not arrive at the internal origin', () => { + expect(restore(createRedirectRequest({ host: 'attacker.example' }))).toBe( + 'attacker.example' + ) + }) + it('restores the first value of an x-forwarded-host list', () => { expect( restore( diff --git a/packages/next/src/server/app-render/action-forwarding.ts b/packages/next/src/server/app-render/action-forwarding.ts index 0c9b4454174d..162f77bdda7f 100644 --- a/packages/next/src/server/app-render/action-forwarding.ts +++ b/packages/next/src/server/app-render/action-forwarding.ts @@ -3,6 +3,8 @@ import type { BaseNextRequest } from '../base-http' import { getRequestMeta } from '../request-meta' import { getServerActionRequestMetadata } from '../lib/server-action-request-meta' import { InvariantError } from '../../shared/lib/invariant-error' +import { RSC_HEADER } from '../../client/components/app-router-headers' +import { isRSCRequestHeader } from '../lib/is-rsc-request' /** * Set by `createForwardedActionResponse` on a Server Action request that it has @@ -19,6 +21,14 @@ export const ACTION_FORWARDED_HEADER = 'x-action-forwarded' */ export const ACTION_FORWARDED_VALUE = '1' +/** + * Set by `createRedirectRenderResult` on the internal RSC request used to + * stream an app-relative Server Action redirect. + */ +export const ACTION_REDIRECT_FORWARDED_HEADER = 'x-action-redirect-forwarded' + +export const ACTION_REDIRECT_FORWARDED_VALUE = '1' + /** * Reads the first value of `x-forwarded-host`, which can arrive either as a * repeated header or as a single comma-separated list. @@ -37,7 +47,7 @@ export function getForwardedHostValue( * The origin Next.js fetches when it forwards a request to itself: action * forwarding (`createForwardedActionResponse`) and app-relative redirect * streaming (`createRedirectRenderResult`) both go here, and - * `restoreForwardedActionHost` uses it to recognize a request that arrived over + * `restoreActionForwardingHost` uses it to recognize a request that arrived over * such a forward. The send and receive sides have to agree, so they share this. * * Throws when no origin can be determined, which is a hard error on the send @@ -66,23 +76,21 @@ export function getActionForwardingOrigin(req: BaseNextRequest): string { } /** - * A Server Action POST that lands on a route which doesn't bundle the action is - * forwarded to a worker that does, by fetching our own forwarding origin (see - * `createForwardedActionResponse`). `host` is a forbidden `fetch` header, so it - * can't be carried over, and the subrequest arrives claiming to be for that + * The internal self-fetches used to forward a Server Action to another worker + * and to stream an app-relative action redirect both derive `host` from the + * forwarding origin. The subrequest therefore arrives claiming to be for that * internal origin instead of the host the user actually requested. * * `x-forwarded-host` does survive the forward, so restore `host` from it. - * Otherwise `headers().get('host')` inside a forwarded action reports - * `localhost:PORT`, which silently breaks host-based multi-tenancy for exactly - * those actions that happen to get forwarded. + * Otherwise `headers().get('host')` inside a forwarded action or its streamed + * redirect target reports `localhost:PORT`, which silently breaks host-based + * multi-tenancy. * - * `x-action-forwarded` is not an authenticated marker, so it is treated as a - * hint rather than as proof: the rewrite additionally requires the request to - * be a fetch action (the only shape that is ever forwarded) that arrived at the - * origin we would have forwarded to. + * Neither internal marker is authenticated, so they are treated as hints rather + * than proof: the rewrite additionally requires the request shape produced by + * its send path and arrival at the origin we would have forwarded to. */ -export function restoreForwardedActionHost( +export function restoreActionForwardingHost( req: BaseNextRequest, { hasConfiguredOrigin, @@ -95,15 +103,19 @@ export function restoreForwardedActionHost( hasConfiguredOrigin: boolean } ): void { - // Not a truthiness check: the header is forgeable, so only the exact value we - // send counts. A repeated header arrives here comma-joined, which also fails. - if (req.headers[ACTION_FORWARDED_HEADER] !== ACTION_FORWARDED_VALUE) { - return - } + const isForwardedAction = + req.headers[ACTION_FORWARDED_HEADER] === ACTION_FORWARDED_VALUE && + getServerActionRequestMetadata(req).isFetchAction + + const isForwardedActionRedirect = + req.headers[ACTION_REDIRECT_FORWARDED_HEADER] === + ACTION_REDIRECT_FORWARDED_VALUE && + req.method === 'GET' && + isRSCRequestHeader(req.headers[RSC_HEADER]) - // `handleAction` only forwards when it has an action id on a POST, so no - // other request shape can have reached us through the forwarding path. - if (!getServerActionRequestMetadata(req).isFetchAction) { + // The markers are forgeable, so only the exact values and request shapes the + // two send paths produce count. Repeated markers are comma-joined and fail. + if (!isForwardedAction && !isForwardedActionRedirect) { return } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 4c4518e93915..a0b137226084 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -51,6 +51,8 @@ import { warn } from '../../build/output/log' import { ACTION_FORWARDED_HEADER, ACTION_FORWARDED_VALUE, + ACTION_REDIRECT_FORWARDED_HEADER, + ACTION_REDIRECT_FORWARDED_VALUE, getActionForwardingOrigin, getForwardedHostValue, } from './action-forwarding' @@ -385,6 +387,10 @@ async function createRedirectRenderResult( const forwardedHeaders = getForwardedHeaders(req, res) forwardedHeaders.set(RSC_HEADER, '1') + forwardedHeaders.set( + ACTION_REDIRECT_FORWARDED_HEADER, + ACTION_REDIRECT_FORWARDED_VALUE + ) const origin = getActionForwardingOrigin(req) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index c5f593e15a96..153daabf8886 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -130,7 +130,7 @@ import { matchNextDataPathname } from './lib/match-next-data-pathname' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' import { RSCPathnameNormalizer } from './normalizers/request/rsc' import { stripFlightHeaders } from './app-render/strip-flight-headers' -import { restoreForwardedActionHost } from './app-render/action-forwarding' +import { restoreActionForwardingHost } from './app-render/action-forwarding' import { isAppPageRouteModule, isAppRouteRouteModule, @@ -1094,12 +1094,12 @@ export default abstract class Server< // it captures the initial URL. this.attachRequestMeta(req, parsedUrl) - // A Server Action that we forwarded to another worker arrives over an - // internal self-fetch, which replaces `host` with the origin we forwarded - // to. This runs after `attachRequestMeta`, because the forwarding origin - // can come from `initURL`, and before the first consumers of `host`: - // domain locale detection below, and later `headers()` inside the action. - restoreForwardedActionHost(req, { + // Internal self-fetches used to forward a Server Action or stream its + // app-relative redirect replace `host` with the forwarding origin. This + // runs after `attachRequestMeta`, because that origin can come from + // `initURL`, and before the first consumers of `host`: domain locale + // detection below, and later userland `headers()`. + restoreActionForwardingHost(req, { // Mirrors the condition `attachRequestMeta` uses to build `initURL` // from this server's own hostname and port. hasConfiguredOrigin: Boolean(this.fetchHostname && this.port), diff --git a/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts index f8f7dca9f92a..49352408ee70 100644 --- a/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts +++ b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts @@ -15,6 +15,10 @@ type ObservedHeaders = { actionForwarded: string | null } +type ObservedRedirectHeaders = ObservedHeaders & { + actionRedirectForwarded: string | null +} + describe('server action forwarding - original host', () => { // The behaviour under test only exists on a self-hosted server: the action is // forwarded over a private loopback fetch to the server's own origin. The @@ -37,7 +41,11 @@ describe('server action forwarding - original host', () => { pathname: string, actionId: string, extraHeaders?: Record - ): Promise<{ status: number }> { + ): Promise<{ + status: number + headers: http.IncomingHttpHeaders + body: string + }> { return new Promise((resolve, reject) => { const request = http.request( { @@ -54,8 +62,15 @@ describe('server action forwarding - original host', () => { }, }, (response) => { - response.resume() - response.on('end', () => resolve({ status: response.statusCode! })) + const chunks: Buffer[] = [] + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + response.on('end', () => + resolve({ + status: response.statusCode!, + headers: response.headers, + body: Buffer.concat(chunks).toString(), + }) + ) response.on('error', reject) } ) @@ -67,8 +82,8 @@ describe('server action forwarding - original host', () => { // Read the action id out of the rendered form rather than the build manifest, // so this works the same in dev and in start. - async function getActionId(): Promise { - const html = await next.render('/with-action') + async function getActionId(pathname = '/with-action'): Promise { + const html = await next.render(pathname) const match = html.match(/\$ACTION_ID_([0-9a-f]{42})/) if (!match) { @@ -98,6 +113,33 @@ describe('server action forwarding - original host', () => { return JSON.parse(match[1]) } + async function collectRedirectObservedHeaders( + pathname: string + ): Promise { + const actionId = await getActionId('/redirect-action') + const outputIndex = next.cliOutput.length + + const response = await postAction(pathname, actionId) + const { status } = response + expect(status).toBe(200) + expect(response.headers['content-type']).toStartWith('text/x-component') + expect(response.headers['x-action-redirect']).toStartWith( + '/redirect-target;' + ) + expect(response.body).toContain(HOST) + + const output = next.cliOutput.slice(outputIndex) + const match = output.match(/\[redirectTarget\](\{.*\})/) + + if (!match) { + throw new Error( + `The redirect target did not render. Output was:\n${output}` + ) + } + + return JSON.parse(match[1]) + } + it('observes the request host when the action is not forwarded', async () => { const observed = await collectObservedHeaders('/with-action') @@ -133,4 +175,35 @@ describe('server action forwarding - original host', () => { expect(observed.xForwardedHost).toBe(FORGED_HOST) expect(observed.host).toBe(HOST) }) + + it('preserves the request host while streaming an action redirect', async () => { + const observed = await collectRedirectObservedHeaders('/redirect-action') + + expect(observed.actionForwarded).toBeNull() + expect(observed.actionRedirectForwarded).toBe('1') + expect(observed.xForwardedHost).toBe(HOST) + expect(observed.host).toBe(HOST) + }) + + it('preserves the request host when a forwarded action streams a redirect', async () => { + const observed = await collectRedirectObservedHeaders( + '/without-redirect-action' + ) + + expect(observed.actionForwarded).toBe('1') + expect(observed.actionRedirectForwarded).toBe('1') + expect(observed.xForwardedHost).toBe(HOST) + expect(observed.host).toBe(HOST) + }) + + it('ignores a forged redirect marker outside a redirected RSC request', async () => { + const observed = await collectObservedHeaders('/with-action', { + 'x-action-redirect-forwarded': '1', + 'x-forwarded-host': FORGED_HOST, + origin: `http://${FORGED_HOST}`, + }) + + expect(observed.xForwardedHost).toBe(FORGED_HOST) + expect(observed.host).toBe(HOST) + }) }) diff --git a/test/e2e/app-dir/action-forward-host/app/redirect-action/actions.ts b/test/e2e/app-dir/action-forward-host/app/redirect-action/actions.ts new file mode 100644 index 000000000000..b1254fef87ab --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/redirect-action/actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { redirect } from 'next/navigation' + +export async function redirectToTarget() { + redirect('/redirect-target') +} diff --git a/test/e2e/app-dir/action-forward-host/app/redirect-action/page.tsx b/test/e2e/app-dir/action-forward-host/app/redirect-action/page.tsx new file mode 100644 index 000000000000..91d160b3dbe1 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/redirect-action/page.tsx @@ -0,0 +1,9 @@ +import { redirectToTarget } from './actions' + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/e2e/app-dir/action-forward-host/app/redirect-target/page.tsx b/test/e2e/app-dir/action-forward-host/app/redirect-target/page.tsx new file mode 100644 index 000000000000..dfaae9db0640 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/redirect-target/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react' +import { headers } from 'next/headers' + +async function Host() { + const headerList = await headers() + const host = headerList.get('host') + + console.log( + `[redirectTarget]` + + JSON.stringify({ + host, + xForwardedHost: headerList.get('x-forwarded-host'), + actionForwarded: headerList.get('x-action-forwarded'), + actionRedirectForwarded: headerList.get('x-action-redirect-forwarded'), + }) + ) + + return
{host}
+} + +export default function Page() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/action-forward-host/app/without-redirect-action/page.tsx b/test/e2e/app-dir/action-forward-host/app/without-redirect-action/page.tsx new file mode 100644 index 000000000000..4921920b8f87 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/app/without-redirect-action/page.tsx @@ -0,0 +1,5 @@ +// This route deliberately does not import the redirecting action, so posting +// that action here exercises both internal self-fetches in sequence. +export default function Page() { + return
without redirect action
+}