From 6bc46f16b8988b8bf7305467726a3e8e1f536e60 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sat, 14 Mar 2026 19:21:52 +0000 Subject: [PATCH] Matrix: use authenticated media downloads --- extensions/matrix/src/matrix/sdk.test.ts | 43 ++++++++++++++++++++++ extensions/matrix/src/matrix/sdk.ts | 47 +++++++++++++++++++----- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index b5170fba545..c70741076bb 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -216,6 +216,49 @@ describe("MatrixClient request hardening", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("prefers authenticated client media downloads", async () => { + const payload = Buffer.from([1, 2, 3, 4]); + const fetchMock = vi.fn(async () => new Response(payload, { status: 200 })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstUrl = String(fetchMock.mock.calls[0]?.[0]); + expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); + }); + + it("falls back to legacy media downloads for older homeservers", async () => { + const payload = Buffer.from([5, 6, 7, 8]); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/_matrix/client/v1/media/download/")) { + return new Response( + JSON.stringify({ + errcode: "M_UNRECOGNIZED", + error: "Unrecognized request", + }), + { + status: 404, + headers: { "content-type": "application/json" }, + }, + ); + } + return new Response(payload, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstUrl = String(fetchMock.mock.calls[0]?.[0]); + const secondUrl = String(fetchMock.mock.calls[1]?.[0]); + expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); + expect(secondUrl).toContain("/_matrix/media/v3/download/example.org/media"); + }); + it("decrypts encrypted room events returned by getEvent", async () => { const client = new MatrixClient("https://matrix.example.org", "token"); matrixJsClient.fetchRoomEvent = vi.fn(async () => ({ diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 31aa518ba87..1d9679ba9c2 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -152,6 +152,20 @@ function isMatrixNotFoundError(err: unknown): boolean { ); } +function isUnsupportedAuthenticatedMediaEndpointError(err: unknown): boolean { + const statusCode = (err as { statusCode?: number })?.statusCode; + if (statusCode === 404 || statusCode === 405 || statusCode === 501) { + return true; + } + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return ( + message.includes("m_unrecognized") || + message.includes("unrecognized request") || + message.includes("method not allowed") || + message.includes("not implemented") + ); +} + export class MatrixClient { private readonly client: MatrixJsClient; private readonly emitter = new EventEmitter(); @@ -625,16 +639,29 @@ export class MatrixClient { if (!parsed) { throw new Error(`Invalid Matrix content URI: ${mxcUrl}`); } - const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`; - const response = await this.httpClient.requestRaw({ - method: "GET", - endpoint, - qs: { allow_remote: opts.allowRemote ?? true }, - timeoutMs: this.localTimeoutMs, - maxBytes: opts.maxBytes, - readIdleTimeoutMs: opts.readIdleTimeoutMs, - }); - return response; + const encodedServer = encodeURIComponent(parsed.server); + const encodedMediaId = encodeURIComponent(parsed.mediaId); + const request = async (endpoint: string): Promise => + await this.httpClient.requestRaw({ + method: "GET", + endpoint, + qs: { allow_remote: opts.allowRemote ?? true }, + timeoutMs: this.localTimeoutMs, + maxBytes: opts.maxBytes, + readIdleTimeoutMs: opts.readIdleTimeoutMs, + }); + + const authenticatedEndpoint = `/_matrix/client/v1/media/download/${encodedServer}/${encodedMediaId}`; + try { + return await request(authenticatedEndpoint); + } catch (err) { + if (!isUnsupportedAuthenticatedMediaEndpointError(err)) { + throw err; + } + } + + const legacyEndpoint = `/_matrix/media/v3/download/${encodedServer}/${encodedMediaId}`; + return await request(legacyEndpoint); } async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise {