fix: stop proxying locale rewrites back to the server when it listens on 127.0.0.1 #1487

This commit is contained in:
Linus Rath
2026-11-01 02:41:42 +01:00
parent d781e8762e
commit d2a83a4ee9
3 changed files with 98 additions and 8 deletions
@@ -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);
});
});
+9
View File
@@ -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 } } : {}),
+13 -8
View File
@@ -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,