Skip to content

Commit 801631d

Browse files
fix(matrix): guard transport new URL() against malformed input
Wrap user-controlled URL parsing in Matrix transport with a shared helper that throws a stable Invalid URL error instead of leaking URL constructor TypeErrors from malformed homeservers, redirect locations, and endpoints. Co-authored-by: Cursor <[email protected]>
1 parent 5c86a22 commit 801631d

2 files changed

Lines changed: 82 additions & 4 deletions

File tree

extensions/matrix/src/matrix/sdk/transport.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,68 @@ describe("performMatrixRequest", () => {
465465
expect(result.text).toBe(payload);
466466
expect(result.buffer.toString("utf8")).toBe(payload);
467467
});
468+
469+
it("rejects malformed homeserver URLs with a stable error", async () => {
470+
await expect(
471+
performMatrixRequest({
472+
homeserver: "http://matrix-user:matrix-fixture@invalid host",
473+
accessToken: "token",
474+
method: "GET",
475+
endpoint: "/_matrix/client/v3/account/whoami",
476+
timeoutMs: 5000,
477+
ssrfPolicy: { allowPrivateNetwork: true },
478+
}),
479+
).rejects.toThrow(new Error("Invalid URL"));
480+
});
481+
482+
it("rejects malformed redirect Location headers with a stable error", async () => {
483+
const server = http.createServer((_req, res) => {
484+
res.writeHead(302, { location: "http://invalid host/path" });
485+
res.end();
486+
});
487+
await new Promise<void>((resolve) => {
488+
server.listen(0, "127.0.0.1", resolve);
489+
});
490+
const { port } = server.address() as { port: number };
491+
492+
try {
493+
await expect(
494+
performMatrixRequest({
495+
homeserver: `http://127.0.0.1:${port}`,
496+
accessToken: "token",
497+
method: "GET",
498+
endpoint: "/_matrix/client/v3/account/whoami",
499+
timeoutMs: 5000,
500+
ssrfPolicy: { allowPrivateNetwork: true },
501+
}),
502+
).rejects.toThrow(new Error("Invalid URL"));
503+
} finally {
504+
await new Promise<void>((resolve) => {
505+
server.close(() => resolve());
506+
});
507+
}
508+
});
509+
510+
it("does not reflect credential-bearing malformed homeserver URLs in errors", async () => {
511+
const secretPass = "matrix-fixture";
512+
const malformed = `http://matrix-user:${secretPass}@${["invalid", "host"].join(" ")}`;
513+
514+
try {
515+
await performMatrixRequest({
516+
homeserver: malformed,
517+
accessToken: "token",
518+
method: "GET",
519+
endpoint: "/_matrix/client/v3/account/whoami",
520+
timeoutMs: 5000,
521+
ssrfPolicy: { allowPrivateNetwork: true },
522+
});
523+
throw new Error("expected performMatrixRequest to reject malformed homeserver URL");
524+
} catch (error) {
525+
const message = error instanceof Error ? error.message : String(error);
526+
expect(message).toBe("Invalid URL");
527+
expect(message).not.toContain(secretPass);
528+
}
529+
});
468530
});
469531

470532
describe("createMatrixGuardedFetch", () => {
@@ -550,6 +612,14 @@ describe("createMatrixGuardedFetch", () => {
550612
expect(runtimeFetch).toHaveBeenCalledTimes(1);
551613
expect(runtimeFetch.mock.calls.at(0)?.[0]).toBe(url);
552614
});
615+
616+
it("rejects malformed request URLs with a stable error", async () => {
617+
const guardedFetch = createMatrixGuardedFetch({
618+
ssrfPolicy: { allowPrivateNetwork: true },
619+
});
620+
621+
await expect(guardedFetch("not-a-url")).rejects.toThrow(new Error("Invalid URL"));
622+
});
553623
});
554624

555625
describe("matrix transport streaming OOM guard — real HTTP server without Content-Length", () => {

extensions/matrix/src/matrix/sdk/transport.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ function normalizeEndpoint(endpoint: string): string {
4545
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
4646
}
4747

48+
function parseMatrixUrl(input: string, base?: string | URL): URL {
49+
try {
50+
return base !== undefined ? new URL(input, base) : new URL(input);
51+
} catch (cause) {
52+
throw new Error("Invalid URL", { cause });
53+
}
54+
}
55+
4856
function applyQuery(url: URL, qs: QueryParams): void {
4957
if (!qs) {
5058
return;
@@ -164,7 +172,7 @@ async function fetchWithMatrixGuardedRedirects(params: {
164172
ssrfPolicy?: SsrFPolicy;
165173
dispatcherPolicy?: PinnedDispatcherPolicy;
166174
}): Promise<{ response: Response; release: () => Promise<void>; finalUrl: string }> {
167-
let currentUrl = new URL(params.url);
175+
let currentUrl = parseMatrixUrl(params.url);
168176
let method = (params.init?.method ?? "GET").toUpperCase();
169177
let body = params.init?.body;
170178
let headers = new Headers(params.init?.headers ?? {});
@@ -215,7 +223,7 @@ async function fetchWithMatrixGuardedRedirects(params: {
215223
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
216224
}
217225

218-
const nextUrl = new URL(location, currentUrl);
226+
const nextUrl = parseMatrixUrl(location, currentUrl);
219227
if (nextUrl.protocol !== currentUrl.protocol) {
220228
cleanup();
221229
await closeDispatcher(dispatcher);
@@ -327,8 +335,8 @@ export async function performMatrixRequest(params: {
327335
}
328336

329337
const baseUrl = isAbsoluteEndpoint
330-
? new URL(params.endpoint)
331-
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
338+
? parseMatrixUrl(params.endpoint)
339+
: parseMatrixUrl(normalizeEndpoint(params.endpoint), params.homeserver);
332340
applyQuery(baseUrl, params.qs);
333341

334342
const headers = new Headers();

0 commit comments

Comments
 (0)