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
+7 -2
View File
@@ -3400,7 +3400,8 @@
"m.audio": {
"error_downloading_audio": "Error downloading audio",
"error_processing_audio": "Error processing audio message",
"error_processing_voice_message": "Error processing voice message"
"error_processing_voice_message": "Error processing voice message",
"error_scan_rejected": "This audio was blocked by the content scanner"
},
"m.beacon_info": {
"view_live_location": "View live location"
@@ -3432,12 +3433,14 @@
"voice_call_unsupported": "%(senderName)s placed a voice call. (not supported by this browser)"
},
"m.file": {
"error_decrypting": "Error decrypting attachment"
"error_decrypting": "Error decrypting attachment",
"error_scan_rejected": "This file was blocked by the content scanner"
},
"m.image": {
"error": "Unable to show image due to error",
"error_decrypting": "Error decrypting image",
"error_downloading": "Error downloading image",
"error_scan_rejected": "This image was blocked by the content scanner",
"sent": "%(senderDisplayName)s sent an image.",
"show_image": "Show image"
},
@@ -3563,6 +3566,7 @@
"m.sticker": "%(senderDisplayName)s sent a sticker.",
"m.video": {
"error_decrypting": "Error decrypting video",
"error_scan_rejected": "This video was blocked by the content scanner",
"show_video": "Show video"
},
"m.widget": {
@@ -3805,6 +3809,7 @@
"title": "Allow guest users to join this room"
},
"upload_failed_generic": "The file '%(fileName)s' failed to upload.",
"upload_failed_scan_rejected": "The file '%(fileName)s' was blocked by the content scanner.",
"upload_failed_size": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
"upload_failed_title": "Upload Failed",
"upload_file": {