From 026def686e954319d69f3d70fa30ca8cc95a31c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:53:57 +0000 Subject: [PATCH] fix(matrix): decrypt E2EE media + size guard (#1744) Thanks @araa47. Co-authored-by: Akshay --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/handler.ts | 10 +++-- .../matrix/src/matrix/monitor/media.test.ts | 42 +++++++++++++++++-- extensions/matrix/src/matrix/monitor/media.ts | 21 ++++++---- extensions/matrix/src/matrix/monitor/types.ts | 1 + 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc7b5370..f5659af14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags ### Fixes +- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. - Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. - BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 2ba7cbef0..4542e113a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - const contentType = - "info" in content && content.info && "mimetype" in content.info - ? (content.info as { mimetype?: string }).mimetype + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = + typeof contentInfo?.size === "number" ? contentInfo.size : undefined; if (mediaUrl?.startsWith("mxc://")) { try { media = await downloadMatrixMedia({ client, mxcUrl: mediaUrl, contentType, + sizeBytes: contentSize, maxBytes: mediaMaxBytes, file: contentFile, }); diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index d8fd51888..10cbd8b47 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => { it("decrypts encrypted media when file payloads are present", async () => { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted")); const client = { - downloadContent, crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), } as unknown as import("matrix-bot-sdk").MatrixClient; @@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => { file, }); - expect(decryptMedia).toHaveBeenCalled(); + // decryptMedia should be called with just the file object (it handles download internally) + expect(decryptMedia).toHaveBeenCalledWith(file); expect(saveMediaBuffer).toHaveBeenCalledWith( Buffer.from("decrypted"), "image/png", @@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => { ); expect(result?.path).toBe("/tmp/media"); }); + + it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + await expect( + downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + sizeBytes: 2048, + maxBytes: 1024, + file, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + + expect(decryptMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index b60320e41..1ade1d19c 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. + * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; @@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: { throw new Error("Cannot decrypt media: crypto not enabled"); } - // Download the encrypted content - const encryptedBuffer = await params.client.downloadContent(params.file.url); - if (encryptedBuffer.byteLength > params.maxBytes) { + // decryptMedia handles downloading and decrypting the encrypted content internally + const decrypted = await params.client.crypto.decryptMedia(params.file); + + if (decrypted.byteLength > params.maxBytes) { throw new Error("Matrix media exceeds configured size limit"); } - // Decrypt using matrix-bot-sdk crypto - const decrypted = await params.client.crypto.decryptMedia( - Buffer.from(encryptedBuffer), - params.file, - ); - return { buffer: decrypted }; } @@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: { client: MatrixClient; mxcUrl: string; contentType?: string; + sizeBytes?: number; maxBytes: number; file?: EncryptedFile; }): Promise<{ @@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: { placeholder: string; } | null> { let fetched: { buffer: Buffer; headerType?: string } | null; + if ( + typeof params.sizeBytes === "number" && + params.sizeBytes > params.maxBytes + ) { + throw new Error("Matrix media exceeds configured size limit"); + } if (params.file) { // Encrypted media diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index d77bdac67..c77cf0282 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & { file?: EncryptedFile; info?: { mimetype?: string; + size?: number; }; "m.relates_to"?: { rel_type?: string;