diff --git a/_pr_meta.json b/_pr_meta.json new file mode 100644 index 00000000..d11745dc --- /dev/null +++ b/_pr_meta.json @@ -0,0 +1,9 @@ +{ + "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", + "url": "https://github.com/Eyevinn/intercom-frontend/pull/671" +} \ No newline at end of file diff --git a/src/utils/call-url.test.ts b/src/utils/call-url.test.ts index 04932d45..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", () => { @@ -130,3 +170,41 @@ 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(); + }); + + 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" + ); + }); +}); diff --git a/src/utils/call-url.ts b/src/utils/call-url.ts index 268087ff..6ab98ff2 100644 --- a/src/utils/call-url.ts +++ b/src/utils/call-url.ts @@ -30,19 +30,40 @@ export function decodeCallsParam(param: string | null): CallRef[] { }, []); } +/** 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 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?:\/\//, ""); - 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; } 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}`; }