mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: stop proxying locale rewrites back to the server when it listens on 127.0.0.1 #1487
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user