diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 969ff1d8..a2ef26c9 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -159,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num return value instanceof ArrayBuffer ? value.byteLength : value.byteLength; } -function gatewayWebSocketOriginAllowed( +export function gatewayWebSocketOriginAllowed( req: Request, target: Target, configured: string[], @@ -174,7 +174,11 @@ function gatewayWebSocketOriginAllowed( } if (configured.includes(origin)) return true; if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true; - return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase()); + // Compare hostnames, not hosts: configured domains carry no port, while the + // browser's Origin does. publicOrigin above only ever matches domains[0], so + // every other domain fell through to here and was denied purely on the port, + // which left the HMR socket reconnecting forever on those hosts. + return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase()); } /** diff --git a/packages/dev-server/test/gateway-ws-origin.test.ts b/packages/dev-server/test/gateway-ws-origin.test.ts new file mode 100644 index 00000000..a362062d --- /dev/null +++ b/packages/dev-server/test/gateway-ws-origin.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts"; + +const target = { + name: "web", + origin: "http://127.0.0.1:3101", + domains: ["localhost", "web.localhost"], + publicOrigin: "http://localhost:3000", +} as any; + +function upgrade(origin: string, host: string): Request { + return new Request("http://" + host + "/__wrnexus/hmr", { + headers: { origin, host, upgrade: "websocket" }, + }); +} + +test("allows an upgrade from the app's primary domain", () => { + expect( + gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []), + ).toBe(true); +}); + +test("allows an upgrade from a secondary domain on a non-default port", () => { + // publicOrigin is built from domains[0], so a browser on web.localhost falls + // through to the domain list — where the origin host still carries :3000 and + // the configured domain does not. That mismatch denied every HMR socket on + // any domain but the first, leaving the client reconnecting forever. + expect( + gatewayWebSocketOriginAllowed( + upgrade("http://web.localhost:3000", "web.localhost:3000"), + target, + [], + ), + ).toBe(true); +}); + +test("still denies an unrelated origin", () => { + expect( + gatewayWebSocketOriginAllowed( + upgrade("http://evil.example:3000", "web.localhost:3000"), + target, + [], + ), + ).toBe(false); +}); + +test("still denies a lookalike suffix domain", () => { + expect( + gatewayWebSocketOriginAllowed( + upgrade("http://notweb.localhost:3000", "web.localhost:3000"), + target, + [], + ), + ).toBe(false); +});