diff --git a/packages/server-v2/src/middleware/origin.ts b/packages/server-v2/src/middleware/origin.ts index 7820010f9..181452a0a 100644 --- a/packages/server-v2/src/middleware/origin.ts +++ b/packages/server-v2/src/middleware/origin.ts @@ -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. * diff --git a/packages/server-v2/test/origin.test.ts b/packages/server-v2/test/origin.test.ts index 7a369ba5d..2fac32420 100644 --- a/packages/server-v2/test/origin.test.ts +++ b/packages/server-v2/test/origin.test.ts @@ -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', () => {