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
@@ -25,6 +25,7 @@ import { FileDownloader } from "../../utils/FileDownloader";
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
import { ContentScanRejectedError } from "../../utils/ContentScanner";
export interface FileBodyViewModelProps {
mxEvent: MatrixEvent;
@@ -249,7 +250,10 @@ export class FileBodyViewModel
logger.warn("Unable to decrypt attachment: ", err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("timeline|m.file|error_decrypting"),
description:
err instanceof ContentScanRejectedError
? _t("timeline|m.file|error_scan_rejected")
: _t("timeline|m.file|error_decrypting"),
});
}
};
@@ -23,6 +23,7 @@ import { mediaFromContent } from "../../customisations/Media";
import { BLURHASH_FIELD } from "../../utils/image-media";
import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../settings/enums/ImageSize";
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
import { ContentScanRejectedError } from "../../utils/ContentScanner";
export interface VideoBodyViewModelProps {
/**
@@ -203,7 +204,10 @@ export class VideoBodyViewModel
if (state.error !== null) {
return {
state: VideoBodyViewState.ERROR,
errorLabel: _t("timeline|m.video|error_decrypting"),
errorLabel:
state.error instanceof ContentScanRejectedError
? _t("timeline|m.video|error_scan_rejected")
: _t("timeline|m.video|error_decrypting"),
maxWidth,
maxHeight,
aspectRatio,