feat: client-side ClamAV scanning for encrypted rooms (Issue #19)

Synapse's own check_media_file_for_spam module can never see E2EE
attachment content - only the client ever holds the decryption key.
Adds two hooks that call a self-hosted scan service
(https://axion1337.chat/_scan, deployed separately in the gitops repo):

- DecryptFile.ts: scans every decrypted attachment (image/audio/video/
  file all funnel through this one function via MediaEventHelper)
  before returning it as a Blob.
- ContentMessages.ts: scans plaintext before encryption/upload in
  uploadFile(), the shared function behind all attachment uploads
  (main file, thumbnails, voice messages), regardless of room
  encryption state.

New ContentScanRejectedError surfaces through the existing error-
rendering paths (MediaProcessingError, upload failure dialog) using
the same pattern as DecryptError/DownloadError/UploadFailedError.

Live-tested: EICAR blocked pre-upload in encrypted rooms and DMs;
receive-side hook also blocks EICAR sent by an unpatched client
(app.element.io), confirming it isn't just self-protection for our
own uploads. Fails open on scanner errors so an outage can't block
all uploads/downloads.
This commit is contained in:
Thore Cimbal
2026-07-29 12:00:00 +00:00
parent 44305556c3
commit 42285021cb
8 changed files with 134 additions and 19 deletions
+21 -2
View File
@@ -49,6 +49,7 @@ import SettingsStore from "./settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
import { TimelineRenderingType } from "./contexts/RoomContext";
import { addReplyToMessageContent } from "./utils/Reply";
import { scanContent, ContentScanRejectedError } from "./utils/ContentScanner";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog";
import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog";
@@ -350,10 +351,24 @@ export async function uploadFile(
): Promise<{ url?: string; file?: EncryptedFile }> {
const abortController = controller ?? new AbortController();
// Issue #19 extension: scan the plaintext before it's ever encrypted or uploaded - the
// one place both directions of client-side scanning meet, since this function backs
// every room-attachment upload (main file, generated thumbnails, and voice messages -
// see VoiceMessageRecording.ts) regardless of whether the target room is encrypted.
// This does mean reading the whole file into memory even for unencrypted-room uploads,
// which previously streamed straight from the File object - unavoidable, since scanning
// requires the bytes in hand either way.
const dataForScan = await readFileAsArrayBuffer(file);
if (abortController.signal.aborted) throw new UploadCanceledError();
const accessTokenForScan = matrixClient.getAccessToken();
if (accessTokenForScan) {
await scanContent(dataForScan, accessTokenForScan);
}
// If the room is encrypted then encrypt the file before uploading it.
if (await matrixClient.getCrypto()?.isEncryptionEnabledInRoom(roomId)) {
// First read the file into memory.
const data = await readFileAsArrayBuffer(file);
// Already read into memory above (dataForScan).
const data = dataForScan;
if (abortController.signal.aborted) throw new UploadCanceledError();
// Then encrypt the file.
@@ -670,6 +685,10 @@ export default class ContentMessages {
desc = _t("upload_failed_size", {
fileName: upload.fileName,
});
} else if (unwrappedError instanceof ContentScanRejectedError) {
desc = _t("upload_failed_scan_rejected", {
fileName: upload.fileName,
});
}
Modal.createDialog(ErrorDialog, {
title: _t("upload_failed_title"),