Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions _pr_meta.json
Original file line number Diff line number Diff line change
@@ -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"
}
78 changes: 78 additions & 0 deletions src/utils/call-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
encodeCallsParam,
decodeCallsParam,
parseCompanionParam,
isValidCompanionHost,
} from "./call-url";

describe("buildCallsUrl", () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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"
);
});
});
29 changes: 25 additions & 4 deletions src/utils/call-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}