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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user