fix(server-v2): treat loopback origins as same-origin for WS upgrade

A browser behind a same-machine dev proxy (e.g. Vite on localhost:5175)
sends Origin localhost while the upstream server is bound to 127.0.0.1;
the two are not string-equal so the WS upgrade was rejected with 403.
Treat loopback<->loopback (localhost / 127.0.0.1 / [::1] / *.localhost)
as same-origin. Public cross-origin is unchanged, auth (401) is unchanged.
This commit is contained in:
7Sageer 2026-07-06 14:17:03 +08:00
parent cb7be13f9a
commit bba9f4489f
2 changed files with 45 additions and 2 deletions

View file

@ -78,14 +78,37 @@ export function isOriginAllowed(
if (oh === undefined) {
return true;
}
if (host !== undefined && stripPort(oh) === stripPort(host)) {
return true;
const ohStripped = stripPort(oh);
if (host !== undefined) {
const hostStripped = stripPort(host);
if (ohStripped === hostStripped) {
return true;
}
// Dev-proxy case: a browser hitting a same-machine dev server (e.g. Vite on
// `localhost:5175`) whose upstream server is bound to a different loopback
// name (e.g. `127.0.0.1:58627`). The two are not string-equal, but both ends
// are loopback, so there is no real cross-origin threat — treat as
// same-origin so WebSocket upgrades are not rejected with 403.
if (isLoopbackHost(ohStripped) && isLoopbackHost(hostStripped)) {
return true;
}
}
// `origin` is defined here (originHost returned a host), so the whitelist
// match is against the full origin string (scheme + host).
return allowed.includes(origin as string);
}
/** Loopback-only host names, mirroring the allowlist in `hostnames.ts`. */
function isLoopbackHost(h: string): boolean {
return (
h === 'localhost' ||
h === '::1' ||
h === '[::1]' ||
h.startsWith('127.') ||
h.endsWith('.localhost')
);
}
/**
* Build the Fastify `onRequest` CORS hook.
*

View file

@ -50,6 +50,26 @@ describe('isOriginAllowed', () => {
it('treats a malformed origin as absent (allowed)', () => {
expect(isOriginAllowed('not a url', 'h', [])).toBe(true);
});
it('treats localhost origin vs 127.0.0.1 host as same-origin (dev proxy)', () => {
expect(isOriginAllowed('http://localhost:5175', '127.0.0.1:58627', [])).toBe(true);
});
it('treats 127.0.0.1 origin vs localhost host as same-origin', () => {
expect(isOriginAllowed('http://127.0.0.1:5175', 'localhost:58627', [])).toBe(true);
});
it('treats [::1] origin vs localhost host as same-origin (IPv6 loopback)', () => {
expect(isOriginAllowed('http://[::1]:5175', 'localhost:58627', [])).toBe(true);
});
it('still denies a non-loopback cross-origin that is not whitelisted', () => {
expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false);
});
it('does not widen to a public host even when the origin is loopback', () => {
expect(isOriginAllowed('http://localhost:5175', 'example.com:80', [])).toBe(false);
});
});
describe('parseCorsOrigins', () => {