fix(matrix): decrypt E2EE media + size guard (#1744)
Thanks @araa47. Co-authored-by: Akshay <araa47@users.noreply.github.com>
This commit is contained in:
@@ -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
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
|
||||||
### Fixes
|
### 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: 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.
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const contentInfo =
|
||||||
"info" in content && content.info && "mimetype" in content.info
|
"info" in content && content.info && typeof content.info === "object"
|
||||||
? (content.info as { mimetype?: string }).mimetype
|
? (content.info as { mimetype?: string; size?: number })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const contentType = contentInfo?.mimetype;
|
||||||
|
const contentSize =
|
||||||
|
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
mxcUrl: mediaUrl,
|
mxcUrl: mediaUrl,
|
||||||
contentType,
|
contentType,
|
||||||
|
sizeBytes: contentSize,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
file: contentFile,
|
file: contentFile,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
|
|
||||||
it("decrypts encrypted media when file payloads are present", async () => {
|
it("decrypts encrypted media when file payloads are present", async () => {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
downloadContent,
|
|
||||||
crypto: { decryptMedia },
|
crypto: { decryptMedia },
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
@@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(decryptMedia).toHaveBeenCalled();
|
// decryptMedia should be called with just the file object (it handles download internally)
|
||||||
|
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||||
Buffer.from("decrypted"),
|
Buffer.from("decrypted"),
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
|
|||||||
);
|
);
|
||||||
expect(result?.path).toBe("/tmp/media");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt encrypted media from a Matrix room.
|
* 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: {
|
async function fetchEncryptedMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
|
|||||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the encrypted content
|
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||||
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
||||||
if (encryptedBuffer.byteLength > params.maxBytes) {
|
|
||||||
|
if (decrypted.byteLength > params.maxBytes) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
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 };
|
return { buffer: decrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
|
|||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let fetched: { buffer: Buffer; headerType?: 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) {
|
if (params.file) {
|
||||||
// Encrypted media
|
// Encrypted media
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
|
|||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
info?: {
|
info?: {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
rel_type?: string;
|
rel_type?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user