fix(matrix): encrypt thumbnails in E2EE rooms using thumbnail_file (#54711)

Merged via squash.

Prepared head SHA: 92be0e1ac2
Co-authored-by: frischeDaten <5878058+frischeDaten@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
frischeDaten
2026-03-28 23:41:23 +01:00
committed by GitHub
parent 468185d1b5
commit 81432d6b7e
4 changed files with 82 additions and 33 deletions

View File

@@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.
- Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
## 2026.3.24

View File

@@ -52,6 +52,7 @@ export type FileWithThumbnailInfo = {
size?: number;
mimetype?: string;
thumbnail_url?: string;
thumbnail_file?: EncryptedFile;
thumbnail_info?: {
w?: number;
h?: number;

View File

@@ -172,19 +172,52 @@ describe("sendMessageMatrix media", () => {
expect(content.file?.url).toBe("mxc://example/file");
});
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
const { client, uploadContent } = makeEncryptedMediaClient();
it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
const { client, sendMessage, uploadContent } = makeClient();
const isRoomEncrypted = vi.fn().mockResolvedValue(true);
const encryptMedia = vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted-thumb"),
file: {
key: { kty: "oct", key_ops: ["encrypt", "decrypt"], alg: "A256CTR", k: "tkey", ext: true },
iv: "tiv",
hashes: { sha256: "thash" },
v: "v2",
},
});
(client as { crypto?: object }).crypto = {
isRoomEncrypted,
encryptMedia,
};
// Return image metadata so thumbnail generation is triggered (image > 800px)
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
.mockResolvedValueOnce({ width: 1920, height: 1080 }) // original image
.mockResolvedValueOnce({ width: 800, height: 450 }); // thumbnail
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb-bytes"));
// Two uploadContent calls: one for the main encrypted image, one for the encrypted thumbnail
uploadContent
.mockResolvedValueOnce("mxc://example/main")
.mockResolvedValueOnce("mxc://example/thumb");
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(1);
// encryptMedia called twice: once for main media, once for thumbnail
expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
expect(encryptMedia).toHaveBeenCalledTimes(2);
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
file?: { url?: string };
info?: { thumbnail_url?: string; thumbnail_file?: { url?: string } };
};
// Main media encrypted correctly
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/main");
// Thumbnail must use thumbnail_file (encrypted), NOT thumbnail_url (unencrypted)
expect(content.info?.thumbnail_url).toBeUndefined();
expect(content.info?.thumbnail_file?.url).toBe("mxc://example/thumb");
});
it("keeps reply context on voice transcript follow-ups outside threads", async () => {
@@ -246,7 +279,7 @@ describe("sendMessageMatrix media", () => {
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
});
it("uploads thumbnail metadata for unencrypted large images", async () => {
it("keeps thumbnail_url metadata for unencrypted large images", async () => {
const { client, sendMessage, uploadContent } = makeClient();
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
@@ -262,6 +295,7 @@ describe("sendMessageMatrix media", () => {
const content = sendMessage.mock.calls[0]?.[1] as {
info?: {
thumbnail_url?: string;
thumbnail_file?: { url?: string };
thumbnail_info?: {
w?: number;
h?: number;
@@ -271,6 +305,7 @@ describe("sendMessageMatrix media", () => {
};
};
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
expect(content.info?.thumbnail_file).toBeUndefined();
expect(content.info?.thumbnail_info).toMatchObject({
w: 800,
h: 600,

View File

@@ -122,10 +122,6 @@ export async function prepareImageInfo(params: {
return undefined;
}
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
if (params.encrypted) {
// For E2EE media, avoid uploading plaintext thumbnails.
return imageInfo;
}
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
@@ -138,12 +134,16 @@ export async function prepareImageInfo(params: {
const thumbMeta = await getCore()
.media.getImageMetadata(thumbBuffer)
.catch(() => null);
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
const result = await uploadMediaWithEncryption(params.client, thumbBuffer, {
contentType: "image/jpeg",
filename: "thumbnail.jpg",
encrypted: params.encrypted === true,
});
if (result.file) {
imageInfo.thumbnail_file = result.file;
} else {
imageInfo.thumbnail_url = result.url;
}
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
@@ -202,6 +202,29 @@ async function uploadFile(
return await client.uploadContent(file, params.contentType, params.filename);
}
async function uploadMediaWithEncryption(
client: MatrixClient,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
encrypted: boolean;
},
): Promise<{ url: string; file?: EncryptedFile }> {
if (params.encrypted && client.crypto) {
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
@@ -215,20 +238,9 @@ export async function uploadMediaMaybeEncrypted(
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
if (isEncrypted && client.crypto) {
// Encrypt the media before uploading
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
// Upload unencrypted
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
const isEncrypted = Boolean(client.crypto && (await client.crypto.isRoomEncrypted(roomId)));
return await uploadMediaWithEncryption(client, buffer, {
...params,
encrypted: isEncrypted,
});
}