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