fix(security): forwarding encrypted media to a plaintext room re-uploads it
buildForwardContent copied content.file (AES key/iv/hashes) verbatim, so forwarding from an E2EE room into an unencrypted one published the key. For unencrypted destinations the attachment is now downloaded, decrypted and re-uploaded as plaintext (url instead of file, thumbnail key stripped); if that fails the forward is refused rather than leaking. Encrypted destinations unchanged. Needs a manual check on a live encrypted -> plaintext forward. Fixes #63 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -42,7 +42,7 @@ import {
|
|||||||
recentForwardTargetsAtom,
|
recentForwardTargetsAtom,
|
||||||
addRecentForwardTarget,
|
addRecentForwardTarget,
|
||||||
} from '../../../state/recentForwardTargets';
|
} from '../../../state/recentForwardTargets';
|
||||||
import { buildForwardContent } from './forwardContent';
|
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -314,12 +314,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const ids = [...selectedRoomIds];
|
const ids = [...selectedRoomIds];
|
||||||
|
|
||||||
|
// `fwdContent.file` (present on encrypted attachments) carries the AES key/iv
|
||||||
|
// needed to decrypt it. Sending it as-is into a room that isn't encrypted would
|
||||||
|
// publish that key in plaintext (Gitea #63), so any unencrypted destination gets
|
||||||
|
// a decrypted-and-re-uploaded plaintext version instead. Built once (not per
|
||||||
|
// room) since every unencrypted destination gets the same re-upload.
|
||||||
|
let plaintextContent: Record<string, unknown> | undefined;
|
||||||
|
let plaintextContentError: string | undefined;
|
||||||
|
if (fwdContent.file) {
|
||||||
|
const needsPlaintext = ids.some((id) => !mx.getRoom(id)?.hasEncryptionStateEvent());
|
||||||
|
if (needsPlaintext) {
|
||||||
|
try {
|
||||||
|
plaintextContent = await buildPlaintextAttachmentContent(
|
||||||
|
mx,
|
||||||
|
fwdContent,
|
||||||
|
useAuthentication,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
plaintextContentError = 'Could not prepare this attachment for an unencrypted room.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const commentBody = comment.trim();
|
const commentBody = comment.trim();
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
|
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
|
||||||
|
// Encrypted destinations keep the original (possibly encrypted-attachment)
|
||||||
|
// content; unencrypted ones get the plaintext version, or fail outright if
|
||||||
|
// that couldn't be built — never fall back to sending the encrypted `file`.
|
||||||
|
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
|
||||||
|
if (fwdContent.file && !destEncrypted && !contentToSend) {
|
||||||
|
return Promise.reject(new Error(plaintextContentError));
|
||||||
|
}
|
||||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
|
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
|
||||||
// Send the optional comment first so it reads as a note above the
|
// Send the optional comment first so it reads as a note above the
|
||||||
// forwarded content. The room counts as failed if either send rejects.
|
// forwarded content. The room counts as failed if either send rejects.
|
||||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||||
@@ -370,7 +401,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
|
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||||
|
import { IEncryptedFile } from '../../../../types/matrix/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the content to forward:
|
* Build the content to forward:
|
||||||
@@ -40,3 +43,44 @@ export function buildForwardContent(
|
|||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `content.file` on an encrypted attachment carries the AES key/iv/hashes
|
||||||
|
* needed to decrypt it. Forwarding that content verbatim into a room that
|
||||||
|
* isn't itself encrypted would publish the key in plaintext to anyone who can
|
||||||
|
* read the destination room (Gitea #63). Re-encrypting for the destination is
|
||||||
|
* out of scope, so instead download+decrypt the attachment here and re-upload
|
||||||
|
* it as a plain (unencrypted) upload, sending `url` in place of `file`.
|
||||||
|
*
|
||||||
|
* Throws if the attachment can't be fetched/decrypted — callers must treat
|
||||||
|
* that as a hard failure for this forward rather than falling back to
|
||||||
|
* sending the encrypted `file` block into the plaintext room.
|
||||||
|
*/
|
||||||
|
export async function buildPlaintextAttachmentContent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
useAuthentication: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const file = content.file as IEncryptedFile;
|
||||||
|
const info = content.info as Record<string, unknown> | undefined;
|
||||||
|
const mimeType = (info?.mimetype as string | undefined) ?? FALLBACK_MIMETYPE;
|
||||||
|
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, file.url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid attachment URL');
|
||||||
|
const blob = await downloadEncryptedMedia(mediaUrl, (buf) => decryptFile(buf, mimeType, file));
|
||||||
|
const uploadResult = await mx.uploadContent(blob, { type: mimeType });
|
||||||
|
|
||||||
|
const plainContent = { ...content };
|
||||||
|
delete plainContent.file;
|
||||||
|
plainContent.url = uploadResult.content_uri;
|
||||||
|
|
||||||
|
// The thumbnail can carry its own encryption key (`thumbnail_file`); drop it
|
||||||
|
// rather than leak it too — the full attachment still forwards fine without
|
||||||
|
// a thumbnail.
|
||||||
|
if (info && (info.thumbnail_file || info.thumbnail_url)) {
|
||||||
|
const { thumbnail_file: _tf, thumbnail_url: _tu, ...restInfo } = info;
|
||||||
|
plainContent.info = restInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plainContent;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user