From bacfef55584ee691563a8d2ccf8f5728485fd359 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:41 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../room/message/ForwardMessageDialog.tsx | 37 ++++++++++++++-- .../features/room/message/forwardContent.ts | 44 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index 5c0063aee..3927ba9d5 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -42,7 +42,7 @@ import { recentForwardTargetsAtom, addRecentForwardTarget, } from '../../../state/recentForwardTargets'; -import { buildForwardContent } from './forwardContent'; +import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent'; type RoomRowProps = { room: Room; @@ -314,12 +314,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { setError(null); 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 | 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 results = await Promise.allSettled( 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. // 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 // forwarded content. The room counts as failed if either send rejects. // Track rooms whose comment already landed so a retry (after the FORWARD @@ -370,7 +401,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { return; } setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`); - }, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]); + }, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]); return ( }> diff --git a/src/app/features/room/message/forwardContent.ts b/src/app/features/room/message/forwardContent.ts index 03b41a624..d490195c0 100644 --- a/src/app/features/room/message/forwardContent.ts +++ b/src/app/features/room/message/forwardContent.ts @@ -1,5 +1,8 @@ import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; 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: @@ -40,3 +43,44 @@ export function buildForwardContent( } 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, + useAuthentication: boolean, +): Promise> { + const file = content.file as IEncryptedFile; + const info = content.info as Record | 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; +}