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:
2026-09-12 20:28:41 -04:00
co-authored by Claude Opus 5
parent 6aa459df31
commit bacfef5558
2 changed files with 78 additions and 3 deletions
@@ -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<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;
}