From fea6f3d7713c38a098293a4e39c6e4fae6a771e6 Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:52:34 +0530 Subject: [PATCH 1/5] fix: Validate companion host before WebSocket URL construction - validate companion host:port before building ws:// URL Fixes #627 Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- src/utils/call-url.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/utils/call-url.ts b/src/utils/call-url.ts index 268087ff..8b5082f5 100644 --- a/src/utils/call-url.ts +++ b/src/utils/call-url.ts @@ -35,14 +35,34 @@ export function buildCallsUrl(calls: CallRef[], companionUrl?: string): string { calls.length === 0 ? "/calls" : `/calls?lines=${encodeCallsParam(calls)}`; let url = base; if (companionUrl) { - const hostPort = companionUrl.replace(/^wss?:\/\//, ""); - const sep = url.includes("?") ? "&" : "?"; - url = `${url}${sep}companion=${hostPort}`; + const hostPort = companionUrl.replace(/^wss?:\/\//i, ""); + if (isValidCompanionHost(hostPort)) { + const sep = url.includes("?") ? "&" : "?"; + url = `${url}${sep}companion=${hostPort}`; + } } return url; } +/** Host[:port] only — reject schemes, paths, userinfo, and other SSRF-friendly shapes. */ +const COMPANION_HOST_RE = /^(?:(?:\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+))(?::\d{1,5})?$/; + +export function isValidCompanionHost(hostPort: string): boolean { + if (!hostPort || hostPort.length > 253) return false; + if (!COMPANION_HOST_RE.test(hostPort)) return false; + // Reject port 0 and oversized ports. + const colon = hostPort.lastIndexOf(":"); + if (colon > 0 && !hostPort.endsWith("]")) { + const port = Number(hostPort.slice(colon + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + } + return true; +} + export function parseCompanionParam(param: string | null): string | undefined { if (!param) return undefined; - return `ws://${param}`; + // Strip accidental scheme if present, then validate host[:port] only. + const hostPort = param.replace(/^wss?:\/\//i, ""); + if (!isValidCompanionHost(hostPort)) return undefined; + return `ws://${hostPort}`; } From 31fb3d20b9fc5158207632afa1e371818faf3d3e Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:44:03 +0530 Subject: [PATCH 2/5] fix: move isValidCompanionHost before use and satisfy prettier Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- src/utils/call-url.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/utils/call-url.ts b/src/utils/call-url.ts index 8b5082f5..6ab98ff2 100644 --- a/src/utils/call-url.ts +++ b/src/utils/call-url.ts @@ -30,22 +30,9 @@ export function decodeCallsParam(param: string | null): CallRef[] { }, []); } -export function buildCallsUrl(calls: CallRef[], companionUrl?: string): string { - const base = - calls.length === 0 ? "/calls" : `/calls?lines=${encodeCallsParam(calls)}`; - let url = base; - if (companionUrl) { - const hostPort = companionUrl.replace(/^wss?:\/\//i, ""); - if (isValidCompanionHost(hostPort)) { - const sep = url.includes("?") ? "&" : "?"; - url = `${url}${sep}companion=${hostPort}`; - } - } - return url; -} - /** Host[:port] only — reject schemes, paths, userinfo, and other SSRF-friendly shapes. */ -const COMPANION_HOST_RE = /^(?:(?:\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+))(?::\d{1,5})?$/; +const COMPANION_HOST_RE = + /^(?:(?:\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+))(?::\d{1,5})?$/; export function isValidCompanionHost(hostPort: string): boolean { if (!hostPort || hostPort.length > 253) return false; @@ -59,6 +46,20 @@ export function isValidCompanionHost(hostPort: string): boolean { return true; } +export function buildCallsUrl(calls: CallRef[], companionUrl?: string): string { + const base = + calls.length === 0 ? "/calls" : `/calls?lines=${encodeCallsParam(calls)}`; + let url = base; + if (companionUrl) { + const hostPort = companionUrl.replace(/^wss?:\/\//i, ""); + if (isValidCompanionHost(hostPort)) { + const sep = url.includes("?") ? "&" : "?"; + url = `${url}${sep}companion=${hostPort}`; + } + } + return url; +} + export function parseCompanionParam(param: string | null): string | undefined { if (!param) return undefined; // Strip accidental scheme if present, then validate host[:port] only. From 6e4027f26fe98c6d314b34f22df99e2af481cd2d Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:02:34 +0530 Subject: [PATCH 3/5] test: reject unsafe companion host inputs Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- _pr_meta.json | 8 ++++++++ src/utils/call-url.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 _pr_meta.json diff --git a/_pr_meta.json b/_pr_meta.json new file mode 100644 index 00000000..83640d85 --- /dev/null +++ b/_pr_meta.json @@ -0,0 +1,8 @@ +{ + "repo": "Eyevinn/intercom-frontend", + "number": 671, + "base": "main", + "head": "fix/issue-627", + "fork": "VedantMadane/Eyevinn-intercom-frontend", + "title": "fix: Validate companion host before WebSocket URL construction" +} \ No newline at end of file diff --git a/src/utils/call-url.test.ts b/src/utils/call-url.test.ts index 04932d45..23f34d32 100644 --- a/src/utils/call-url.test.ts +++ b/src/utils/call-url.test.ts @@ -130,3 +130,32 @@ describe("parseCompanionParam", () => { expect(parseCompanionParam("example.com")).toBe("ws://example.com"); }); }); + +describe("parseCompanionParam rejects unsafe hosts", () => { + it.each([ + "attacker.com/malicious", + "user@evil.com", + "//evil.com", + "host:99999", + "host:0", + "evil.com/path", + " user.com ", + ])("rejects %s", (raw) => { + expect(parseCompanionParam(raw)).toBeUndefined(); + }); + + it("accepts a bare host and host:port", () => { + expect(parseCompanionParam("companion.example")).toBe("ws://companion.example"); + expect(parseCompanionParam("companion.example:8080")).toBe( + "ws://companion.example:8080" + ); + }); + + it("strips accidental ws scheme then validates", () => { + expect(parseCompanionParam("ws://companion.example:8080")).toBe( + "ws://companion.example:8080" + ); + expect(parseCompanionParam("ws://user@evil.com")).toBeUndefined(); + }); +}); + From 43950275748545f7383e68f22c9296f0c8e79b5d Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 17 Sep 2026 13:41:46 +0000 Subject: [PATCH 4/5] test: add regression tests for companion host validation (#627) Cover isValidCompanionHost edge cases (empty, localhost[:port], IPv6 literals, scheme/path rejection, out-of-range and zero ports, over-length host), buildCallsUrl companion param absence for invalid hosts and presence for valid ones, and parseCompanionParam scheme normalisation. Co-Authored-By: Claude Opus 4.7 --- src/utils/call-url.test.ts | 53 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/utils/call-url.test.ts b/src/utils/call-url.test.ts index 23f34d32..4d27ab3a 100644 --- a/src/utils/call-url.test.ts +++ b/src/utils/call-url.test.ts @@ -4,6 +4,7 @@ import { encodeCallsParam, decodeCallsParam, parseCompanionParam, + isValidCompanionHost, } from "./call-url"; describe("buildCallsUrl", () => { @@ -88,6 +89,26 @@ describe("buildCallsUrl round-trip", () => { }); }); +describe("isValidCompanionHost", () => { + // Regression: locks in the SSRF-hardening behaviour so future edits to the + // host[:port] validator cannot silently reintroduce scheme/path/userinfo or + // out-of-range port acceptance. + it.each([ + ["localhost", true], + ["localhost:8080", true], + ["[::1]", true], + ["[::1]:443", true], + ["", false], + ["http://evil.com", false], + ["host/path", false], + ["host:99999", false], // port out of range + ["host:0", false], // port zero + ["a".repeat(254), false], // over-length host (> 253) + ])("%s -> %s", (host, expected) => { + expect(isValidCompanionHost(host)).toBe(expected); + }); +}); + describe("buildCallsUrl — companion URL", () => { it("appends companion param by stripping the ws:// prefix", () => { const url = buildCallsUrl( @@ -115,6 +136,25 @@ describe("buildCallsUrl — companion URL", () => { "/calls?lines=p1:l1" ); }); + + it("omits the companion param entirely for an invalid host", () => { + // Regression: an SSRF-shaped companion must never leak into the URL. + const url = buildCallsUrl( + [{ productionId: "p1", lineId: "l1" }], + "http://evil.com/path" + ); + expect(url).toBe("/calls?lines=p1:l1"); + expect(url).not.toContain("companion="); + }); + + it("includes the companion param for a valid host", () => { + const url = buildCallsUrl( + [{ productionId: "p1", lineId: "l1" }], + "ws://companion.example:8080" + ); + expect(url).toBe("/calls?lines=p1:l1&companion=companion.example:8080"); + expect(url).toContain("companion=companion.example:8080"); + }); }); describe("parseCompanionParam", () => { @@ -145,7 +185,9 @@ describe("parseCompanionParam rejects unsafe hosts", () => { }); it("accepts a bare host and host:port", () => { - expect(parseCompanionParam("companion.example")).toBe("ws://companion.example"); + expect(parseCompanionParam("companion.example")).toBe( + "ws://companion.example" + ); expect(parseCompanionParam("companion.example:8080")).toBe( "ws://companion.example:8080" ); @@ -157,5 +199,12 @@ describe("parseCompanionParam rejects unsafe hosts", () => { ); expect(parseCompanionParam("ws://user@evil.com")).toBeUndefined(); }); -}); + it("strips an accidental wss scheme and re-wraps a valid host as ws://", () => { + // Real behaviour: the scheme is normalised away and the returned URL is + // always ws:// regardless of the incoming ws:// or wss:// prefix. + expect(parseCompanionParam("wss://companion.example:8080")).toBe( + "ws://companion.example:8080" + ); + }); +}); From d317173ad152bf505dd88ab3cede336a3897832c Mon Sep 17 00:00:00 2001 From: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:10:20 +0530 Subject: [PATCH 5/5] style: prettier companion host validation helpers Signed-off-by: Vedant Madane <6527493+VedantMadane@users.noreply.github.com> --- _pr_meta.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/_pr_meta.json b/_pr_meta.json index 83640d85..d11745dc 100644 --- a/_pr_meta.json +++ b/_pr_meta.json @@ -4,5 +4,6 @@ "base": "main", "head": "fix/issue-627", "fork": "VedantMadane/Eyevinn-intercom-frontend", - "title": "fix: Validate companion host before WebSocket URL construction" + "title": "fix: Validate companion host before WebSocket URL construction", + "url": "https://github.com/Eyevinn/intercom-frontend/pull/671" } \ No newline at end of file