diff --git a/apps/web/src/ContentMessages.ts b/apps/web/src/ContentMessages.ts index ec68f854f..a9f2ba112 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -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"), diff --git a/apps/web/src/components/views/messages/MAudioBody.tsx b/apps/web/src/components/views/messages/MAudioBody.tsx index 997d32b75..3ffcf7abe 100644 --- a/apps/web/src/components/views/messages/MAudioBody.tsx +++ b/apps/web/src/components/views/messages/MAudioBody.tsx @@ -21,9 +21,10 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex import MediaProcessingError from "./shared/MediaProcessingError"; import { AudioPlayerViewModel } from "../../../viewmodels/room/timeline/event-tile/body/AudioPlayerViewModel"; import { FileBodyFactory, renderMBody } from "./MBodyFactory"; +import { ContentScanRejectedError } from "../../../utils/ContentScanner"; interface IState { - error?: boolean; + error?: unknown; playback?: Playback; } @@ -40,12 +41,12 @@ export default class MAudioBody extends React.PureComponent const blob = await this.props.mediaEventHelper!.sourceBlob.value; buffer = await blob.arrayBuffer(); } catch (e) { - this.setState({ error: true }); + this.setState({ error: e }); logger.warn("Unable to decrypt audio message", e); return; // stop processing the audio file } } catch (e) { - this.setState({ error: true }); + this.setState({ error: e }); logger.warn("Unable to decrypt/download audio message", e); return; // stop processing the audio file } @@ -81,11 +82,11 @@ export default class MAudioBody extends React.PureComponent public render(): React.ReactNode { if (this.state.error) { - return ( - - {_t("timeline|m.audio|error_processing_audio")} - - ); + const errorText = + this.state.error instanceof ContentScanRejectedError + ? _t("timeline|m.audio|error_scan_rejected") + : _t("timeline|m.audio|error_processing_audio"); + return {errorText}; } if (this.props.forExport) { diff --git a/apps/web/src/components/views/messages/MImageBody.tsx b/apps/web/src/components/views/messages/MImageBody.tsx index 351a40e00..90af0e078 100644 --- a/apps/web/src/components/views/messages/MImageBody.tsx +++ b/apps/web/src/components/views/messages/MImageBody.tsx @@ -34,6 +34,7 @@ import { presentableTextForFile } from "../../../utils/FileUtils"; import { createReconnectedListener } from "../../../utils/connection"; import MediaProcessingError from "./shared/MediaProcessingError"; import { DecryptError, DownloadError } from "../../../utils/DecryptFile"; +import { ContentScanRejectedError } from "../../../utils/ContentScanner"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; import { isMimeTypeAllowed } from "../../../utils/blobs.ts"; import { FileBodyFactory, renderMBody } from "./MBodyFactory"; @@ -673,6 +674,8 @@ export class MImageBodyInner extends React.Component { errorText = _t("timeline|m.image|error_decrypting"); } else if (this.state.error instanceof DownloadError) { errorText = _t("timeline|m.image|error_downloading"); + } else if (this.state.error instanceof ContentScanRejectedError) { + errorText = _t("timeline|m.image|error_scan_rejected"); } return ( diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 74a978f70..5d5c4d9b2 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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": { diff --git a/apps/web/src/utils/ContentScanner.ts b/apps/web/src/utils/ContentScanner.ts new file mode 100644 index 000000000..a6c7786e6 --- /dev/null +++ b/apps/web/src/utils/ContentScanner.ts @@ -0,0 +1,63 @@ +/* +Copyright 2026 aXion1337 + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +// Client-side ClamAV scanning (Issue #19 extension): Synapse's own server-side spam-checker +// module can never see E2EE attachment content - only a cooperating client can, since only +// the client ever holds the room's decryption key. This calls a small self-hosted scan +// service (same ClamAV instance the server-side module uses) directly from the browser, both +// before encrypting/uploading a file and after downloading/decrypting one - see +// ContentMessages.ts (send) and DecryptFile.ts (receive) for the two call sites. + +import { logger } from "matrix-js-sdk/src/logger"; + +export class ContentScanRejectedError extends Error { + public readonly signature: string; + + public constructor(signature: string) { + super(`Blocked by content scanner: ${signature}`); + this.name = "ContentScanRejectedError"; + this.signature = signature; + } +} + +/** + * Scans plaintext bytes against the self-hosted ClamAV scan service. + * Fails open (resolves normally) on any network/scanner-side error, matching the same + * fail-open policy as the server-side Synapse module - a scanner outage should not block + * uploads or downloads site-wide. + * @throws {ContentScanRejectedError} if the scanner positively identifies the content as infected. + */ +export async function scanContent(data: ArrayBuffer | ArrayBufferView, accessToken: string): Promise { + let response: Response; + try { + response = await fetch("/_scan", { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + body: data as BodyInit, + }); + } catch (e) { + logger.warn("Content scan request failed (scanner unreachable?) - allowing through", e); + return; + } + + if (!response.ok) { + logger.warn("Content scan request failed with status", response.status, "- allowing through"); + return; + } + + let result: { clean: boolean; signature?: string }; + try { + result = await response.json(); + } catch (e) { + logger.warn("Content scan response was not valid JSON - allowing through", e); + return; + } + + if (result.clean === false) { + throw new ContentScanRejectedError(result.signature ?? "unknown"); + } +} diff --git a/apps/web/src/utils/DecryptFile.ts b/apps/web/src/utils/DecryptFile.ts index b159b526d..ef97baf4e 100644 --- a/apps/web/src/utils/DecryptFile.ts +++ b/apps/web/src/utils/DecryptFile.ts @@ -13,6 +13,8 @@ import { type EncryptedFile, type MediaEventInfo } from "matrix-js-sdk/src/types import { mediaFromContent } from "../customisations/Media"; import { getBlobSafeMimeType } from "./blobs"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { scanContent, ContentScanRejectedError } from "./ContentScanner"; export class DownloadError extends Error { public constructor(e: Error) { @@ -30,6 +32,8 @@ export class DecryptError extends Error { } } +export { ContentScanRejectedError }; + /** * Decrypt a file attached to a matrix event. * @param {EncryptedFile} file The encrypted file information taken from the matrix event. @@ -55,19 +59,31 @@ export async function decryptFile(file?: EncryptedFile, info?: MediaEventInfo): throw new DownloadError(e as Error); } + let dataArray: ArrayBuffer; + let mimetype: string; try { // Decrypt the array buffer using the information taken from the event content. - const dataArray = await encrypt.decryptAttachment(responseData, file!); - // Turn the array into a Blob and give it the correct MIME-type. + dataArray = await encrypt.decryptAttachment(responseData, file!); // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise // they introduce XSS attacks if the Blob URI is viewed directly in the // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. - const mimetype = getBlobSafeMimeType(info?.mimetype?.split(";")[0].trim() ?? ""); - - return new Blob([dataArray], { type: mimetype }); + mimetype = getBlobSafeMimeType(info?.mimetype?.split(";")[0].trim() ?? ""); } catch (e) { throw new DecryptError(e as Error); } + + // Issue #19 extension: Synapse's own media scanner never sees this content (it's + // ciphertext to the server) - this is the one place in the whole app where decrypted + // plaintext for *every* attachment type first exists, so scanning here covers all of + // them in one spot. Deliberately outside the try/catch above: a scan rejection is a + // distinct outcome from a decrypt failure, not wrapped as a DecryptError. + const accessToken = MatrixClientPeg.safeGet()?.getAccessToken(); + if (accessToken) { + await scanContent(dataArray, accessToken); + } + + // Turn the array into a Blob and give it the correct MIME-type. + return new Blob([dataArray], { type: mimetype }); } diff --git a/apps/web/src/viewmodels/message-body/FileBodyViewModel.ts b/apps/web/src/viewmodels/message-body/FileBodyViewModel.ts index a3d7113f1..0e08b706f 100644 --- a/apps/web/src/viewmodels/message-body/FileBodyViewModel.ts +++ b/apps/web/src/viewmodels/message-body/FileBodyViewModel.ts @@ -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"), }); } }; diff --git a/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts b/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts index 95b0226bb..fdb4bb1b6 100644 --- a/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts +++ b/apps/web/src/viewmodels/message-body/VideoBodyViewModel.ts @@ -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,