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
87 lines
3.7 KiB
TypeScript
87 lines
3.7 KiB
TypeScript
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:
|
|
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
|
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
|
* original pre-edit body
|
|
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
|
* message stands alone in the target room
|
|
*/
|
|
export function buildForwardContent(
|
|
mx: MatrixClient,
|
|
mEvent: MatrixEvent,
|
|
): Record<string, unknown> | undefined {
|
|
if (mEvent.isDecryptionFailure()) return undefined;
|
|
|
|
let content = { ...mEvent.getContent() };
|
|
|
|
const eventId = mEvent.getId();
|
|
const room = mx.getRoom(mEvent.getRoomId());
|
|
if (eventId && room) {
|
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
|
const newContent = editedEvent?.getContent()['m.new_content'];
|
|
if (newContent && typeof newContent === 'object') {
|
|
content = { ...(newContent as Record<string, unknown>) };
|
|
}
|
|
}
|
|
|
|
delete content['m.relates_to'];
|
|
// Drop intentional mentions so forwarding a message doesn't re-ping the
|
|
// originally-mentioned users (they're not in the destination room's context).
|
|
delete content['m.mentions'];
|
|
if (typeof content.body === 'string') {
|
|
content.body = trimReplyFromBody(content.body);
|
|
}
|
|
if (typeof content.formatted_body === 'string') {
|
|
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
|
}
|
|
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;
|
|
}
|