Matrix: use authenticated media downloads

This commit is contained in:
Gustavo Madeira Santana 2026-03-14 19:21:52 +00:00
parent 9ab8df25d3
commit 6bc46f16b8
No known key found for this signature in database
2 changed files with 80 additions and 10 deletions

View File

@ -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 () => ({

View File

@ -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<Buffer> =>
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<string> {