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..1557bf8b491e --- /dev/null +++ b/packages/next/src/server/app-render/action-forwarding.test.ts @@ -0,0 +1,315 @@ +import type { IncomingHttpHeaders } from 'http' +import type { BaseNextRequest } from '../base-http' +import { NEXT_REQUEST_META } from '../request-meta' +import { + getActionForwardingOrigin, + getForwardedHostValue, + restoreActionForwardingHost, +} 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, + }) +} + +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( + 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('restoreActionForwardingHost', () => { + 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) { + restoreActionForwardingHost(req, { hasConfiguredOrigin }) + return req.headers['host'] + } + + it('restores the original host on a forwarded action', () => { + 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( + 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 new file mode 100644 index 000000000000..162f77bdda7f --- /dev/null +++ b/packages/next/src/server/app-render/action-forwarding.ts @@ -0,0 +1,149 @@ +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' +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 + * 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' + +/** + * 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' + +/** + * 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. + */ +export function getForwardedHostValue( + headers: IncomingHttpHeaders +): string | undefined { + const forwardedHostHeader = headers['x-forwarded-host'] + + return Array.isArray(forwardedHostHeader) + ? forwardedHostHeader[0] + : 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 + * `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 + * 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 } + ) + } +} + +/** + * 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 or its streamed + * redirect target reports `localhost:PORT`, which silently breaks host-based + * multi-tenancy. + * + * 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 restoreActionForwardingHost( + 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 { + 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]) + + // 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 + } + + 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(getActionForwardingOrigin(req)).host + } catch { + // We can't tell where a forward would have gone, so don't rewrite anything. + return + } + + // The request has to have arrived at the origin we forward to. + if (req.headers['host'] !== internalHost) { + return + } + + 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 1156f88da65e..a0b137226084 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -48,6 +48,14 @@ import { import { getServerActionRequestMetadata } from '../lib/server-action-request-meta' import { isCsrfOriginAllowed } from './csrf-protection' 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' import { RequestCookies, ResponseCookies } from '../web/spec-extension/cookies' import { HeadersAdapter } from '../web/spec-extension/adapters/headers' import { fromNodeOutgoingHttpHeaders } from '../web/utils' @@ -225,26 +233,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('x-action-forwarded', '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}`) @@ -396,26 +387,12 @@ async function createRedirectRenderResult( const forwardedHeaders = getForwardedHeaders(req, res) forwardedHeaders.set(RSC_HEADER, '1') + forwardedHeaders.set( + ACTION_REDIRECT_FORWARDED_HEADER, + ACTION_REDIRECT_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}${appRelativeRedirectUrl.pathname}${appRelativeRedirectUrl.search}` @@ -512,11 +489,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 +727,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..153daabf8886 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 { restoreActionForwardingHost } from './app-render/action-forwarding' import { isAppPageRouteModule, isAppRouteRouteModule, @@ -1093,6 +1094,17 @@ export default abstract class Server< // it captures the initial URL. this.attachRequestMeta(req, parsedUrl) + // 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), + }) + 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 new file mode 100644 index 000000000000..49352408ee70 --- /dev/null +++ b/test/e2e/app-dir/action-forward-host/action-forward-host.test.ts @@ -0,0 +1,209 @@ +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' + +// 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 + 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 + // 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, + extraHeaders?: Record + ): Promise<{ + status: number + headers: http.IncomingHttpHeaders + body: string + }> { + 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, + ...extraHeaders, + }, + }, + (response) => { + 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) + } + ) + + 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(pathname = '/with-action'): Promise { + const html = await next.render(pathname) + 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, + extraHeaders?: Record + ): Promise { + const actionId = await getActionId() + const outputIndex = next.cliOutput.length + + const { status } = await postAction(pathname, actionId, extraHeaders) + 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]) + } + + 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') + + // 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) + }) + + 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) + }) + + 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/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/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/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/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
+} 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