From d2a83a4ee9d2bf68d08ebc9895741c3bdcb719be Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:41:42 +0100 Subject: [PATCH] fix: stop proxying locale rewrites back to the server when it listens on 127.0.0.1 #1487 --- lib/__tests__/proxy-loopback-origin.test.ts | 76 +++++++++++++++++++++ next.config.ts | 9 +++ proxy.ts | 21 +++--- 3 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 lib/__tests__/proxy-loopback-origin.test.ts diff --git a/lib/__tests__/proxy-loopback-origin.test.ts b/lib/__tests__/proxy-loopback-origin.test.ts new file mode 100644 index 000000000..4fa64e4a1 --- /dev/null +++ b/lib/__tests__/proxy-loopback-origin.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + * + * On HOSTNAME=127.0.0.1, Next renames the host in request.nextUrl to + * "localhost" while its router keeps 127.0.0.1. A proxy rewrite or redirect + * built from nextUrl then points at another origin: rewrites were proxied + * back to the server itself (over TLS when X-Forwarded-Proto said https, an + * EPROTO 500), and with skipProxyUrlNormalize redirects would send the + * browser to localhost (#1046, #1487). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +let setupState: 'configured' | 'bootstrap' = 'configured'; + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn((_key: string, fallback: unknown) => fallback), + getPolicy: vi.fn(() => ({ features: {} })), + }, +})); +vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => setupState) })); +vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) })); +vi.mock('next-intl/middleware', async () => { + const { NextResponse } = await import('next/server'); + return { default: () => () => NextResponse.next() }; +}); + +import { proxy } from '@/proxy'; + +beforeEach(() => { + // What the build inlines for skipProxyUrlNormalize: true. NextRequest then + // keeps request.url as given instead of the renamed nextUrl. + vi.stubEnv('__NEXT_NO_MIDDLEWARE_URL_NORMALIZE', 'true'); +}); + +afterEach(() => { + setupState = 'configured'; + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe('proxy redirects on a loopback listen address', () => { + it('sends a configured install from /setup to /admin/login on the same origin', async () => { + const response = await proxy(new NextRequest('https://127.0.0.1:3000/setup')); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('https://127.0.0.1:3000/admin/login'); + }); + + it('sends every page to /setup on the same origin while the wizard runs, keeping the query', async () => { + setupState = 'bootstrap'; + const response = await proxy(new NextRequest('http://[::1]:3000/mail?x=1')); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://[::1]:3000/setup?x=1'); + }); + + it('keeps the base path of a sub-path install', async () => { + const request = new NextRequest('https://127.0.0.1:3000/webmail/setup', { + nextConfig: { basePath: '/webmail' }, + }); + const response = await proxy(request); + + expect(response.headers.get('location')).toBe('https://127.0.0.1:3000/webmail/admin/login'); + }); +}); + +describe('next.config', () => { + it('leaves proxy rewrites on the URL the router built', async () => { + vi.resetModules(); + const { default: config } = await import('../../next.config'); + expect(config.skipProxyUrlNormalize).toBe(true); + }); +}); diff --git a/next.config.ts b/next.config.ts index d039caec1..e8cfc98f7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,6 +52,15 @@ const nextConfig: NextConfig = { // No `X-Powered-By: Next.js`; headers() below sends // `X-Powered-By: bulwarkmail.org` instead, without a version. poweredByHeader: false, + // Keep proxy rewrites on the origin the server listens on. Next renames a + // 127.x or [::1] host to "localhost" in each proxy rewrite, but its router + // compares the rewrite with the listen address. On HOSTNAME=127.0.0.1 every + // locale rewrite then looked external and was proxied back to the server + // itself, over TLS when X-Forwarded-Proto said https: EPROTO and a 500 + // behind a reverse proxy (#1046, #1487, vercel/next.js#94745). With this set, + // request.url is the URL the router built; proxy.ts builds its redirects + // from it as well. + skipProxyUrlNormalize: true, // Directory-style pages (out/en/mail/index.html) are what static hosts and // the shipped SPA-fallback rules expect; next/image has no optimizer here. ...(isLite ? { trailingSlash: true, images: { unoptimized: true } } : {}), diff --git a/proxy.ts b/proxy.ts index 12f1423ed..6660020bd 100644 --- a/proxy.ts +++ b/proxy.ts @@ -187,6 +187,17 @@ function isSetupPath(pathname: string): boolean { ); } +/** + * A redirect to `pathname` (without the base path) on the origin of + * request.url. Not request.nextUrl: Next renames a 127.x or [::1] host there + * to "localhost", and with skipProxyUrlNormalize (next.config.ts) Next no + * longer turns that back into a relative Location. On HOSTNAME=127.0.0.1 the + * browser would be sent to https://localhost:3000/... (#1487). + */ +function redirectTo(request: NextRequest, pathname: string, search: string): NextResponse { + return NextResponse.redirect(new URL(`${request.nextUrl.basePath}${pathname}${search}`, request.url)); +} + export async function proxy(request: NextRequest) { // The probes answer without config or store, so a replica that is still // starting (or lost its shared store) can say so (#1066). @@ -231,10 +242,7 @@ export async function proxy(request: NextRequest) { { status: 503, headers: { "content-type": "application/json" } }, ); } - const url = request.nextUrl.clone(); - url.pathname = "/setup"; - url.search = request.nextUrl.search; - return NextResponse.redirect(url); + return redirectTo(request, "/setup", request.nextUrl.search); } } else if (isSetupPath(pathname)) { // Configured / env-managed: wizard is no longer reachable. @@ -244,10 +252,7 @@ export async function proxy(request: NextRequest) { if (pathname.startsWith("/api/setup")) { return new NextResponse("Not Found", { status: 404 }); } - const url = request.nextUrl.clone(); - url.pathname = "/admin/login"; - url.search = ""; - return NextResponse.redirect(url); + return redirectTo(request, "/admin/login", ""); } // Outer CSRF gate for every API route (GHSA-qvr9-m8cq-7wvg,