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.
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
/*
|
|
* Copyright 2026 Element Creations Ltd.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
import { type MouseEvent, type RefObject } from "react";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
|
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
|
import {
|
|
BaseViewModel,
|
|
FileBodyViewState,
|
|
FileBodyViewInfoIcon,
|
|
type FileBodyViewSnapshot,
|
|
type FileBodyViewModel as FileBodyViewModelInterface,
|
|
} from "@element-hq/web-shared-components";
|
|
|
|
import Modal from "../../Modal";
|
|
import { _t } from "../../languageHandler";
|
|
import { mediaFromContent } from "../../customisations/Media";
|
|
import { downloadLabelForFile, presentableTextForFile } from "../../utils/FileUtils";
|
|
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;
|
|
mediaEventHelper?: MediaEventHelper;
|
|
forExport?: boolean;
|
|
showFileInfo?: boolean;
|
|
timelineRenderingType: TimelineRenderingType;
|
|
refIFrame: RefObject<HTMLIFrameElement>;
|
|
refLink: RefObject<HTMLAnchorElement>;
|
|
}
|
|
|
|
// Cached copy of the download.svg asset for the sandboxed iframe.
|
|
const downloadIconCache = { url: "" };
|
|
|
|
async function cacheDownloadIcon(): Promise<string> {
|
|
if (downloadIconCache.url) return downloadIconCache.url;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const svg = await fetch(require("@vector-im/compound-design-tokens/icons/download.svg").default).then((r) =>
|
|
r.text(),
|
|
);
|
|
downloadIconCache.url = "data:image/svg+xml;base64," + window.btoa(svg);
|
|
return downloadIconCache.url;
|
|
}
|
|
|
|
// Cache the asset immediately
|
|
// noinspection JSIgnoredPromiseFromCall
|
|
cacheDownloadIcon();
|
|
|
|
// User supplied content can contain scripts, we have to be careful that
|
|
// we don't accidentally run those script within the same origin as the
|
|
// client. Otherwise those scripts written by remote users can read
|
|
// the access token and end-to-end keys that are in local storage.
|
|
//
|
|
// For attachments downloaded directly from the homeserver we can use
|
|
// Content-Security-Policy headers to disable script execution.
|
|
//
|
|
// But attachments with end-to-end encryption are more difficult to handle.
|
|
// We need to decrypt the attachment on the client and then display it.
|
|
// To display the attachment we need to turn the decrypted bytes into a URL.
|
|
//
|
|
// There are two ways to turn bytes into URLs, data URL and blob URLs.
|
|
// Data URLs aren't suitable for downloading a file because Chrome has a
|
|
// 2MB limit on the size of URLs that can be viewed in the browser or
|
|
// downloaded. This limit does not seem to apply when the url is used as
|
|
// the source attribute of an image tag.
|
|
//
|
|
// Blob URLs are generated using window.URL.createObjectURL and unfortunately
|
|
// for our purposes they inherit the origin of the page that created them.
|
|
// This means that any scripts that run when the URL is viewed will be able
|
|
// to access local storage.
|
|
//
|
|
// The easiest solution is to host the code that generates the blob URL on
|
|
// a different domain to the client.
|
|
// Another possibility is to generate the blob URL within a sandboxed iframe.
|
|
// The downside of using a second domain is that it complicates hosting,
|
|
// the downside of using a sandboxed iframe is that the browers are overly
|
|
// restrictive in what you are allowed to do with the generated URL.
|
|
|
|
/**
|
|
* Get the current CSS style for a DOMElement.
|
|
* @param {HTMLElement} element The element to get the current style of.
|
|
* @return {string} The CSS style encoded as a string.
|
|
*/
|
|
function computedStyle(element: HTMLElement | null): string {
|
|
if (!element) {
|
|
return "";
|
|
}
|
|
const style = window.getComputedStyle(element, null);
|
|
let cssText = style.cssText;
|
|
// noinspection EqualityComparisonWithCoercionJS
|
|
if (cssText === "") {
|
|
// Firefox doesn't implement ".cssText" for computed styles.
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=137687
|
|
for (const rule of style) {
|
|
cssText += rule + ":";
|
|
cssText += style.getPropertyValue(rule) + ";";
|
|
}
|
|
}
|
|
return cssText;
|
|
}
|
|
|
|
export class FileBodyViewModel
|
|
extends BaseViewModel<FileBodyViewSnapshot, FileBodyViewModelProps>
|
|
implements FileBodyViewModelInterface
|
|
{
|
|
private readonly refIFrame: RefObject<HTMLIFrameElement>;
|
|
private readonly refLink: RefObject<HTMLAnchorElement>;
|
|
private decryptedBlob?: Blob;
|
|
private userDidClick = false;
|
|
private readonly fileDownloader: FileDownloader;
|
|
|
|
public constructor(props: FileBodyViewModelProps) {
|
|
super(props, FileBodyViewModel.computeSnapshot(props));
|
|
this.refIFrame = props.refIFrame;
|
|
this.refLink = props.refLink;
|
|
this.fileDownloader = new FileDownloader(() => this.refIFrame.current);
|
|
}
|
|
|
|
private static getInfoIcon(content: MediaEventContent): FileBodyViewInfoIcon {
|
|
if (content.msgtype === MsgType.Audio) {
|
|
return FileBodyViewInfoIcon.AUDIO;
|
|
} else if (content.msgtype === MsgType.Video) {
|
|
return FileBodyViewInfoIcon.VIDEO;
|
|
}
|
|
return FileBodyViewInfoIcon.ATTACHMENT;
|
|
}
|
|
|
|
private static computeSnapshot(props: FileBodyViewModelProps, decryptedBlob?: Blob): FileBodyViewSnapshot {
|
|
const content = props.mxEvent.getContent<MediaEventContent>();
|
|
const media = mediaFromContent(content);
|
|
const isEncrypted = props.mediaEventHelper?.media.isEncrypted === true;
|
|
|
|
//Whether or not to show the default placeholder for the file. Defaults to true.
|
|
const showFileInfo = props.showFileInfo ?? true;
|
|
|
|
const showDownload =
|
|
!showFileInfo &&
|
|
props.timelineRenderingType !== TimelineRenderingType.Room &&
|
|
props.timelineRenderingType !== TimelineRenderingType.Search &&
|
|
props.timelineRenderingType !== TimelineRenderingType.Pinned &&
|
|
props.timelineRenderingType !== TimelineRenderingType.Thread;
|
|
|
|
const fileInfoLabel = showFileInfo
|
|
? presentableTextForFile(content, _t("common|attachment"), true, true)
|
|
: undefined;
|
|
const fileInfoTooltip = showFileInfo
|
|
? presentableTextForFile(content, _t("common|attachment"), true)
|
|
: undefined;
|
|
const fileInfoIcon = showFileInfo ? FileBodyViewModel.getInfoIcon(content) : undefined;
|
|
const downloadLabel = showDownload ? downloadLabelForFile(content, true) : undefined;
|
|
const downloadTitle = showDownload
|
|
? presentableTextForFile(content, _t("common|attachment"), true, true)
|
|
: undefined;
|
|
|
|
if (props.forExport) {
|
|
return {
|
|
state: FileBodyViewState.EXPORT,
|
|
showInfo: showFileInfo,
|
|
infoLabel: fileInfoLabel,
|
|
infoTooltip: fileInfoTooltip,
|
|
infoIcon: fileInfoIcon,
|
|
infoHref: content.file?.url || content.url,
|
|
};
|
|
}
|
|
|
|
if (isEncrypted) {
|
|
const state = decryptedBlob ? FileBodyViewState.ENCRYPTED : FileBodyViewState.DECRYPTION_PENDING;
|
|
|
|
return {
|
|
state,
|
|
showInfo: showFileInfo,
|
|
infoLabel: fileInfoLabel,
|
|
infoTooltip: fileInfoTooltip,
|
|
infoIcon: fileInfoIcon,
|
|
showDownload,
|
|
downloadLabel,
|
|
downloadTitle: downloadTitle,
|
|
};
|
|
}
|
|
|
|
if (media.srcHttp) {
|
|
return {
|
|
state: FileBodyViewState.UNENCRYPTED,
|
|
showInfo: showFileInfo,
|
|
infoLabel: fileInfoLabel,
|
|
infoTooltip: fileInfoTooltip,
|
|
infoIcon: fileInfoIcon,
|
|
showDownload,
|
|
downloadLabel,
|
|
downloadTitle: downloadTitle,
|
|
downloadHref: media.srcHttp,
|
|
};
|
|
}
|
|
|
|
return {
|
|
state: FileBodyViewState.INVALID,
|
|
showInfo: showFileInfo,
|
|
infoLabel: fileInfoLabel,
|
|
infoTooltip: fileInfoTooltip,
|
|
infoIcon: fileInfoIcon,
|
|
infoHref: content.file?.url || content.url,
|
|
};
|
|
}
|
|
|
|
private get content(): MediaEventContent {
|
|
return this.props.mxEvent.getContent<MediaEventContent>();
|
|
}
|
|
|
|
private get fileName(): string {
|
|
return this.props.mediaEventHelper?.fileName || _t("common|attachment");
|
|
}
|
|
|
|
private get linkText(): string {
|
|
return downloadLabelForFile(this.content, true);
|
|
}
|
|
|
|
private downloadFile(fileName: string, text: string): void {
|
|
if (!this.decryptedBlob) return;
|
|
|
|
this.fileDownloader.download({
|
|
blob: this.decryptedBlob,
|
|
name: fileName,
|
|
autoDownload: this.userDidClick,
|
|
opts: {
|
|
imgSrc: downloadIconCache.url,
|
|
imgStyle: null,
|
|
style: computedStyle(this.refLink.current),
|
|
textContent: text,
|
|
},
|
|
});
|
|
}
|
|
|
|
private decryptFile = async (): Promise<void> => {
|
|
if (this.decryptedBlob || !this.props.mediaEventHelper) {
|
|
return;
|
|
}
|
|
try {
|
|
this.userDidClick = true;
|
|
this.decryptedBlob = await this.props.mediaEventHelper.sourceBlob.value;
|
|
this.snapshot.set(FileBodyViewModel.computeSnapshot(this.props, this.decryptedBlob));
|
|
} catch (err) {
|
|
logger.warn("Unable to decrypt attachment: ", err);
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("common|error"),
|
|
description:
|
|
err instanceof ContentScanRejectedError
|
|
? _t("timeline|m.file|error_scan_rejected")
|
|
: _t("timeline|m.file|error_decrypting"),
|
|
});
|
|
}
|
|
};
|
|
|
|
public onInfoClick = async (): Promise<void> => {
|
|
if (this.props.forExport || !(this.props.showFileInfo ?? true) || !this.props.mediaEventHelper) {
|
|
return;
|
|
}
|
|
|
|
if (this.props.mediaEventHelper.media.isEncrypted) {
|
|
await this.decryptFile();
|
|
this.downloadFile(this.fileName, this.linkText);
|
|
return;
|
|
}
|
|
|
|
this.fileDownloader.download({
|
|
blob: await this.props.mediaEventHelper.sourceBlob.value,
|
|
name: this.fileName,
|
|
});
|
|
};
|
|
|
|
public onDownloadClick = (): Promise<void> => this.decryptFile();
|
|
|
|
public onDownloadLinkClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!this.props.mediaEventHelper) return;
|
|
|
|
const fileType = this.content.info?.mimetype ?? "application/octet-stream";
|
|
logger.log(`Downloading ${fileType} as blob (unencrypted)`);
|
|
|
|
this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const tempAnchor = document.createElement("a");
|
|
tempAnchor.download = this.fileName;
|
|
tempAnchor.href = blobUrl;
|
|
document.body.appendChild(tempAnchor);
|
|
tempAnchor.click();
|
|
tempAnchor.remove();
|
|
});
|
|
};
|
|
|
|
public onDownloadIframeLoad = (): void => {
|
|
this.downloadFile(this.fileName, this.linkText);
|
|
};
|
|
|
|
public setProps(newProps: Partial<FileBodyViewModelProps>): void {
|
|
const oldEvent = this.props.mxEvent;
|
|
this.props = { ...this.props, ...newProps };
|
|
|
|
if (this.props.mxEvent !== oldEvent) {
|
|
this.decryptedBlob = undefined;
|
|
this.userDidClick = false;
|
|
}
|
|
|
|
this.snapshot.set(FileBodyViewModel.computeSnapshot(this.props, this.decryptedBlob));
|
|
}
|
|
}
|