fix(gateway): allow HMR sockets on every configured domain
The WebSocket origin check compared the browser's Origin host, which carries the port, against configured domains, which do not. publicOrigin only ever matches domains[0], so every other domain fell through to that comparison and was denied purely on the port: web.localhost:3000 never matched web.localhost. The result was a 403 on the HMR upgrade and a client reconnecting forever, while the page itself loaded fine because HTTP routing resolves the Host separately. Compares hostnames now. Unrelated and lookalike-suffix origins are still denied, and both cases are covered by tests. Verified through a real gateway: the HMR socket opens on both localhost and web.localhost, and a live edit reaches the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user