fix(composer): stop three E2EE plaintext leaks (compress, schedule, GIF)
- Image compression in an encrypted room re-encoded the *plaintext* original, uploaded it unencrypted, and reused the original's encInfo, so the media sat on the server in the clear AND the attachment was undecryptable. The compressed bytes are now run through encryptFile and the synthetic upload item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can never inherit the stale encInfo). - Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the SDK encryption pipeline. The Schedule button is now hidden in encrypted rooms, handleScheduleClick no-ops there, and scheduleMessage() itself refuses with a clear error so no caller can regress this. README notes the limitation. - The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now mirrors the voice/attachment path (encryptFile -> upload ciphertext -> content.file). Fixes #6 Fixes #7 Fixes #11 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -105,6 +105,7 @@ import {
|
||||
settingsAtom,
|
||||
} from '../../state/settings';
|
||||
import {
|
||||
buildCompressedUploadItem,
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
@@ -244,8 +245,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
// Schedule-send is hidden in thread mode (v1 reduction).
|
||||
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
|
||||
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
|
||||
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
|
||||
const showSchedule =
|
||||
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
|
||||
const composerButtonOrder = useMemo(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
@@ -485,22 +489,29 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||||
type: compressedType,
|
||||
});
|
||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||
name: compressedName,
|
||||
type: compressedType,
|
||||
});
|
||||
// Compression re-encodes the image, so in an encrypted room the new
|
||||
// bytes must be encrypted before upload (and the event must carry the
|
||||
// *new* encInfo) — reusing the original's encInfo would publish the
|
||||
// image in the clear and yield an undecryptable attachment.
|
||||
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
|
||||
const uploadRes = encrypted
|
||||
? await mx.uploadContent(encrypted.file)
|
||||
: await mx.uploadContent(compressedFile, {
|
||||
name: compressedName,
|
||||
type: compressedType,
|
||||
});
|
||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (compressedMxc) {
|
||||
// Delete the pre-uploaded original so only one copy lives on the server.
|
||||
tryDeleteMxcContent(mx, upload.mxc);
|
||||
mxc = compressedMxc;
|
||||
// Build a synthetic fileItem that refers to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions and type.
|
||||
const compressedItem = {
|
||||
...fileItem,
|
||||
file: compressedFile,
|
||||
originalFile: compressedFile,
|
||||
};
|
||||
// Synthetic fileItem referring to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions, type and encInfo.
|
||||
const compressedItem = buildCompressedUploadItem(
|
||||
fileItem,
|
||||
compressedFile,
|
||||
encrypted,
|
||||
);
|
||||
return getImageMsgContent(mx, compressedItem, mxc);
|
||||
}
|
||||
}
|
||||
@@ -697,11 +708,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
||||
|
||||
const handleScheduleClick = useCallback(() => {
|
||||
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
||||
// open the modal for an encrypted room even if the button somehow renders.
|
||||
if (isEncrypted) return;
|
||||
// Pre-fill from editor if there's content; open blank if editor is empty.
|
||||
const content = buildCurrentTextContent();
|
||||
setScheduleContent(content);
|
||||
setScheduleOpen(true);
|
||||
}, [buildCurrentTextContent]);
|
||||
}, [buildCurrentTextContent, isEncrypted]);
|
||||
|
||||
const handleScheduled = useCallback(
|
||||
(delayId: string, sendAt: number, content: IContent) => {
|
||||
@@ -823,18 +837,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadRes = await mx.uploadContent(
|
||||
new File([blob], 'image.gif', { type: 'image/gif' }),
|
||||
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
|
||||
);
|
||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (!mxcUrl) return;
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
|
||||
const baseContent = {
|
||||
msgtype: MsgType.Image,
|
||||
body: 'image.gif',
|
||||
url: mxcUrl,
|
||||
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
||||
});
|
||||
};
|
||||
|
||||
// Mirror the attachment/voice paths: in an encrypted room the media
|
||||
// itself must be encrypted, otherwise the homeserver (and anyone with
|
||||
// the mxc URI) can see the GIF even though the event body is encrypted.
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const { encInfo, file: encBlob } = await encryptFile(gifFile);
|
||||
const uploadRes = await mx.uploadContent(encBlob);
|
||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (!mxcUrl) return;
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
...baseContent,
|
||||
file: { ...encInfo, url: mxcUrl },
|
||||
} as any);
|
||||
} else {
|
||||
const uploadRes = await mx.uploadContent(gifFile, {
|
||||
type: 'image/gif',
|
||||
name: 'image.gif',
|
||||
includeFilename: false,
|
||||
});
|
||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (!mxcUrl) return;
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
...baseContent,
|
||||
url: mxcUrl,
|
||||
} as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
|
||||
if (!alive()) return;
|
||||
@@ -844,7 +878,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
if (alive()) setGifUploading(false);
|
||||
}
|
||||
},
|
||||
[mx, roomId, threadRootId, alive],
|
||||
[mx, room, roomId, threadRootId, alive],
|
||||
);
|
||||
|
||||
const handleStickerSelect = useCallback(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { buildCompressedUploadItem } from './msgContent';
|
||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
|
||||
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
|
||||
// (if any) the resulting m.image event carries. Getting this wrong either leaks
|
||||
// a plaintext image into an E2EE room or produces an undecryptable attachment.
|
||||
|
||||
const enc = (tag: string): EncryptedAttachmentInfo =>
|
||||
({
|
||||
v: 'v2',
|
||||
key: { alg: 'A256CTR', k: tag },
|
||||
iv: `iv-${tag}`,
|
||||
hashes: { sha256: `sha-${tag}` },
|
||||
}) as unknown as EncryptedAttachmentInfo;
|
||||
|
||||
const fakeFile = (name: string, size: number): File =>
|
||||
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
|
||||
|
||||
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
|
||||
({
|
||||
file: fakeFile('photo.png', 900),
|
||||
originalFile: fakeFile('photo.png', 900),
|
||||
encInfo,
|
||||
metadata: { markedAsSpoiler: false, compressImage: true },
|
||||
}) as unknown as TUploadItem;
|
||||
|
||||
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
|
||||
const compressed = fakeFile('photo.jpg', 300);
|
||||
const item = buildCompressedUploadItem(makeItem(), compressed);
|
||||
|
||||
assert.equal(item.file, compressed);
|
||||
assert.equal(item.originalFile, compressed);
|
||||
assert.equal(item.encInfo, undefined);
|
||||
});
|
||||
|
||||
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
|
||||
const compressed = fakeFile('photo.jpg', 300);
|
||||
const encryptedBlob = fakeFile('photo.jpg', 320);
|
||||
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
|
||||
file: encryptedBlob,
|
||||
encInfo: enc('compressed'),
|
||||
});
|
||||
|
||||
// The ciphertext is what gets uploaded; the plaintext stays available for
|
||||
// dimensions/blurhash only.
|
||||
assert.equal(item.file, encryptedBlob);
|
||||
assert.equal(item.originalFile, compressed);
|
||||
assert.deepEqual(item.encInfo, enc('compressed'));
|
||||
assert.notDeepEqual(item.encInfo, enc('original'));
|
||||
});
|
||||
|
||||
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
|
||||
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
|
||||
// stale encInfo (that is the bug this helper exists to prevent).
|
||||
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
|
||||
assert.equal(item.encInfo, undefined);
|
||||
});
|
||||
|
||||
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
|
||||
const base = makeItem();
|
||||
base.metadata.caption = 'a caption';
|
||||
base.metadata.markedAsSpoiler = true;
|
||||
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
|
||||
assert.equal(item.metadata.caption, 'a caption');
|
||||
assert.equal(item.metadata.markedAsSpoiler, true);
|
||||
});
|
||||
|
||||
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
|
||||
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
|
||||
// msgContent.test.ts builders, which share the same shape.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
|
||||
import to from 'await-to-js';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import {
|
||||
IThumbnailContent,
|
||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||
@@ -43,6 +44,28 @@ const generateThumbnailContent = async (
|
||||
return thumbnailContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the synthetic upload item for a *re-encoded* (compressed) image.
|
||||
*
|
||||
* The compressed bytes are a brand new payload, so the item must never inherit
|
||||
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
|
||||
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
|
||||
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
|
||||
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
|
||||
*/
|
||||
export const buildCompressedUploadItem = (
|
||||
item: TUploadItem,
|
||||
compressedFile: File,
|
||||
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
|
||||
): TUploadItem => ({
|
||||
...item,
|
||||
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
|
||||
// for dimensions + blurhash.
|
||||
file: encrypted?.file ?? compressedFile,
|
||||
originalFile: compressedFile,
|
||||
encInfo: encrypted?.encInfo,
|
||||
});
|
||||
|
||||
export const getImageMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { scheduleMessage } from './scheduledMessages';
|
||||
|
||||
// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling
|
||||
// must be refused outright for encrypted rooms — the composer hides the button,
|
||||
// this guard stops any other caller from regressing it.
|
||||
const makeMx = (encrypted: boolean | undefined) => {
|
||||
const calls: unknown[][] = [];
|
||||
const mx = {
|
||||
getRoom: (_roomId: string) =>
|
||||
encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted },
|
||||
http: {
|
||||
authedRequest: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return Promise.resolve({ delay_id: 'delay-1' });
|
||||
},
|
||||
},
|
||||
};
|
||||
return { mx: mx as never, calls };
|
||||
};
|
||||
|
||||
test('scheduleMessage throws and sends nothing for an encrypted room', async () => {
|
||||
const { mx, calls } = makeMx(true);
|
||||
await assert.rejects(
|
||||
() => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000),
|
||||
/encrypted rooms/,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('scheduleMessage still sends for an unencrypted room', async () => {
|
||||
const { mx, calls } = makeMx(false);
|
||||
const delayId = await scheduleMessage(
|
||||
mx,
|
||||
'!plain:lotusguild.org',
|
||||
{ body: 'hi' },
|
||||
Date.now() + 60_000,
|
||||
);
|
||||
assert.equal(delayId, 'delay-1');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('scheduleMessage sends when the room is unknown to the client', async () => {
|
||||
const { mx, calls } = makeMx(undefined);
|
||||
await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
@@ -12,6 +12,13 @@ export async function scheduleMessage(
|
||||
content: IContent,
|
||||
sendAtMs: number,
|
||||
): Promise<string> {
|
||||
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
|
||||
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
|
||||
// (and later in the timeline) in the clear. Refuse rather than leak; the
|
||||
// composer also hides the Schedule button in encrypted rooms.
|
||||
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
|
||||
throw new Error('Scheduled messages are not supported in encrypted rooms.');
|
||||
}
|
||||
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
||||
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
||||
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||
|
||||
Reference in New Issue
Block a user