diff --git a/src/shared/utils/wsPath.ts b/src/shared/utils/wsPath.ts index 84cd08f445e9..515e1a1cd523 100644 --- a/src/shared/utils/wsPath.ts +++ b/src/shared/utils/wsPath.ts @@ -23,6 +23,75 @@ export function deriveLiveWsPath(publicUrl?: string): string { } } +/** + * Validate the live WebSocket port reported by the handshake endpoint. + * + * The WebSocket server exposes its actual listening port, which may differ from + * the compiled-in default. Only a valid TCP port should ever override the + * default URL. + */ +export function sanitizeLiveWsPort(port: unknown): number | null { + if (typeof port === "number") { + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return port; + } + + if (typeof port === "string") { + const trimmed = port.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const numericPort = Number(trimmed); + if (!Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535) return null; + return numericPort; + } + + return null; +} + +/** + * Resolve the browser's live dashboard WebSocket URL from the handshake values. + * + * Priority: + * 1. explicit wsUrl passed by the caller + * 2. publicUrl reported by the handshake + * 3. default URL with the runtime port and path applied + * 4. the original default URL + */ +export function resolveLiveWsUrl({ + explicit, + handshakeUrl, + handshakePort, + handshakePath, + defaultUrl, +}: { + explicit?: string; + handshakeUrl?: string | null; + handshakePort?: number | string | null; + handshakePath?: string | null; + defaultUrl: string; +}): string { + if (typeof explicit === "string") { + const trimmed = explicit.trim(); + if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) return trimmed; + } + + if (typeof handshakeUrl === "string") { + const trimmed = handshakeUrl.trim(); + if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) return trimmed; + } + + try { + const parsed = new URL(defaultUrl); + const sanitizedPort = sanitizeLiveWsPort(handshakePort); + if (sanitizedPort !== null) parsed.port = String(sanitizedPort); + if (typeof handshakePath === "string" && handshakePath.startsWith("/")) { + parsed.pathname = handshakePath; + } + return parsed.toString(); + } catch { + return defaultUrl; + } +} + /** * The operator-declared public WebSocket URL, resolved at RUNTIME. * diff --git a/tests/unit/live-ws-url-11331.test.ts b/tests/unit/live-ws-url-11331.test.ts index 83fc56a21637..db580b57e0ef 100644 --- a/tests/unit/live-ws-url-11331.test.ts +++ b/tests/unit/live-ws-url-11331.test.ts @@ -27,11 +27,20 @@ describe("sanitizeLiveWsPort", () => { assert.equal(sanitizeLiveWsPort(value), null, `expected null for ${String(value)}`); } }); + + it("rejects hexadecimal numeric strings", () => { + assert.equal(sanitizeLiveWsPort("0x10"), null); + }); + + it("rejects scientific-notation numeric strings", () => { + assert.equal(sanitizeLiveWsPort("1e3"), null); + }); }); describe("resolveLiveWsUrl", () => { it("uses the port the handshake reports instead of the compiled-in one", () => { const url = resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: DEFAULT_URL }); + assert.equal(new URL(url).port, "20140"); assert.equal(new URL(url).hostname, "omniroute.example.tld"); assert.equal(new URL(url).pathname, "/live-ws"); @@ -51,14 +60,25 @@ describe("resolveLiveWsUrl", () => { it("applies the port and the path together", () => { const url = new URL( - resolveLiveWsUrl({ handshakePort: 9443, handshakePath: "/ws/live", defaultUrl: DEFAULT_URL }) + resolveLiveWsUrl({ + handshakePort: 9443, + handshakePath: "/ws/live", + defaultUrl: DEFAULT_URL, + }) ); + assert.equal(url.port, "9443"); assert.equal(url.pathname, "/ws/live"); }); it("ignores a path that is not a path", () => { - const url = new URL(resolveLiveWsUrl({ handshakePath: "live-ws", defaultUrl: DEFAULT_URL })); + const url = new URL( + resolveLiveWsUrl({ + handshakePath: "live-ws", + defaultUrl: DEFAULT_URL, + }) + ); + assert.equal(url.pathname, "/live-ws"); }); @@ -85,8 +105,34 @@ describe("resolveLiveWsUrl", () => { ); }); + it("rejects an explicit non-websocket URL", () => { + assert.equal( + resolveLiveWsUrl({ + explicit: "http://weird", + defaultUrl: DEFAULT_URL, + }), + DEFAULT_URL + ); + }); + + it("rejects a handshake URL that is not a websocket URL", () => { + assert.equal( + resolveLiveWsUrl({ + handshakeUrl: "https://nope", + defaultUrl: DEFAULT_URL, + }), + DEFAULT_URL + ); + }); + it("falls back to the default rather than throwing on an unparseable default", () => { - assert.equal(resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: "not a url" }), "not a url"); + assert.equal( + resolveLiveWsUrl({ + handshakePort: 20140, + defaultUrl: "not a url", + }), + "not a url" + ); }); it("leaves deriveLiveWsPath alone", () => {