From db77785b6b887460a873e7a56775f36f5f308afd Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Wed, 29 Jul 2026 15:29:47 +0200 Subject: [PATCH] feat: real media antivirus scanning via custom Synapse module (Issue #19) Deploys ClamAV and a small stdlib-only Synapse spam-checker module implementing check_media_file_for_spam over clamd's INSTREAM protocol. Unlike the originally-considered matrix-content-scanner proxy (which needs client-side cooperation neither Element Web nor Element X provide), this hooks Synapse's own module API directly - transparent to every client for unencrypted media. No custom Synapse image needed: the module is mounted via a ConfigMap onto PYTHONPATH using the ESS chart's extraVolumes/extraVolumeMounts/extraEnv support. Fails open on scanner errors so a ClamAV outage can't block all uploads. --- apps/production/clamav-pvc.yaml | 12 +++ apps/production/clamav.yaml | 66 +++++++++++++ apps/production/clamav_spam_checker.py | 94 +++++++++++++++++++ .../custom-configs/synapse-values.yaml | 25 ++++- apps/production/kustomization.yaml | 14 +++ apps/production/networkpolicy.yaml | 22 +++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/production/clamav-pvc.yaml create mode 100644 apps/production/clamav.yaml create mode 100644 apps/production/clamav_spam_checker.py diff --git a/apps/production/clamav-pvc.yaml b/apps/production/clamav-pvc.yaml new file mode 100644 index 0000000..271be11 --- /dev/null +++ b/apps/production/clamav-pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: clamav-data + namespace: matrix +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 3Gi diff --git a/apps/production/clamav.yaml b/apps/production/clamav.yaml new file mode 100644 index 0000000..c3ca180 --- /dev/null +++ b/apps/production/clamav.yaml @@ -0,0 +1,66 @@ +apiVersion: v1 +kind: Service +metadata: + name: clamav + namespace: matrix +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: clamav + ports: + - name: clamd + port: 3310 + protocol: TCP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: clamav + namespace: matrix +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: clamav + template: + metadata: + labels: + app.kubernetes.io/name: clamav + spec: + containers: + - name: clamav + image: clamav/clamav:1.5.3 + imagePullPolicy: IfNotPresent + ports: + - name: clamd + containerPort: 3310 + volumeMounts: + - name: data + mountPath: /var/lib/clamav + resources: + requests: + cpu: 100m + memory: 1.5Gi + limits: + memory: 3Gi + # clamd needs the full signature DB downloaded (freshclam, can take several + # minutes on first start) before it accepts connections - the image's own + # healthcheck script accounts for this via a long StartPeriod. + livenessProbe: + exec: + command: ["clamdcheck.sh"] + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 10 + readinessProbe: + exec: + command: ["clamdcheck.sh"] + initialDelaySeconds: 60 + periodSeconds: 15 + failureThreshold: 20 + volumes: + - name: data + persistentVolumeClaim: + claimName: clamav-data diff --git a/apps/production/clamav_spam_checker.py b/apps/production/clamav_spam_checker.py new file mode 100644 index 0000000..0563971 --- /dev/null +++ b/apps/production/clamav_spam_checker.py @@ -0,0 +1,94 @@ +# Synapse spam-checker module (Issue #19): scans locally-stored and remote/federated media +# through ClamAV's clamd daemon via its native INSTREAM protocol, before Synapse serves it. +# +# Talks to clamd using only the standard library (asyncio/socket) - no third-party +# dependency, since Synapse's container runs with a read-only root filesystem and this +# module is mounted in via a ConfigMap, not baked into a custom image. +# +# clamd INSTREAM protocol (docs.clamav.net/manual/Usage/ClamdProtocol.html): +# 1. send b"zINSTREAM\0" +# 2. send one or more chunks, each framed as a 4-byte big-endian length + that many +# data bytes +# 3. send a zero-length chunk (b"\x00\x00\x00\x00") to signal end of stream +# 4. read the reply: "stream: OK\0" (clean) or "stream: FOUND\0" (infected) +# +# Fails open (allows the file through) on any connection/timeout error against clamd, +# so a scanner outage can't take down media uploads for the whole homeserver - logged +# loudly so an outage is still visible in the logs. + +import asyncio +import logging +from typing import Any, Union + +from synapse.module_api import ModuleApi, NOT_SPAM +from synapse.module_api.errors import Codes + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 2**14 # matches ReadableFileWrapper.CHUNK_SIZE +CLAMD_TIMEOUT_SECONDS = 30 + + +class ClamAVSpamChecker: + def __init__(self, config: dict, api: ModuleApi): + self.api = api + self.clamd_host = config["clamd_host"] + self.clamd_port = config["clamd_port"] + self.api.register_spam_checker_callbacks( + check_media_file_for_spam=self.check_media_file_for_spam, + ) + + @staticmethod + def parse_config(config: dict) -> dict: + if "clamd_host" not in config or "clamd_port" not in config: + raise ValueError( + "clamav_spam_checker config requires 'clamd_host' and 'clamd_port'" + ) + return config + + async def check_media_file_for_spam( + self, file_wrapper: Any, file_info: Any + ) -> Union[Any, Codes, bool]: + buffer = bytearray() + await file_wrapper.write_chunks_to(buffer.extend) + + try: + verdict = await asyncio.wait_for( + self._scan(bytes(buffer)), timeout=CLAMD_TIMEOUT_SECONDS + ) + except Exception: + logger.exception( + "ClamAV scan failed (clamd at %s:%s unreachable?) - " + "allowing file through (fail-open)", + self.clamd_host, + self.clamd_port, + ) + return NOT_SPAM + + if verdict is None: + return NOT_SPAM + + logger.warning("ClamAV rejected an upload: %s", verdict) + return Codes.FORBIDDEN + + async def _scan(self, data: bytes) -> "str | None": + """Returns the detected signature name, or None if the file is clean.""" + reader, writer = await asyncio.open_connection(self.clamd_host, self.clamd_port) + try: + writer.write(b"zINSTREAM\0") + for offset in range(0, len(data), CHUNK_SIZE): + chunk = data[offset : offset + CHUNK_SIZE] + writer.write(len(chunk).to_bytes(4, "big") + chunk) + writer.write((0).to_bytes(4, "big")) + await writer.drain() + + response = await reader.read() + finally: + writer.close() + await writer.wait_closed() + + text = response.decode("utf-8", errors="replace").strip("\x00 \n") + # "stream: OK" or "stream: FOUND" + if text.endswith("FOUND"): + return text.removeprefix("stream:").removesuffix("FOUND").strip() + return None diff --git a/apps/production/custom-configs/synapse-values.yaml b/apps/production/custom-configs/synapse-values.yaml index eb142d8..89c5048 100644 --- a/apps/production/custom-configs/synapse-values.yaml +++ b/apps/production/custom-configs/synapse-values.yaml @@ -10,6 +10,22 @@ data: rootLevel: INFO levelOverrides: synapse.media.url_previewer: DEBUG + # ClamAV media scanning module (Issue #19) - mounted read-only from a ConfigMap + # (synapse-clamav-module) since the container runs with a read-only root filesystem + # and we avoid a custom Synapse image; PYTHONPATH picks it up for the `modules:` + # block below. + extraVolumes: + - name: clamav-spam-checker + configMap: + name: synapse-clamav-module + extraVolumeMounts: + - name: clamav-spam-checker + mountPath: /extra-modules/clamav_spam_checker.py + subPath: clamav_spam_checker.py + readOnly: true + extraEnv: + - name: PYTHONPATH + value: /extra-modules additional: url-previews: config: | @@ -60,4 +76,11 @@ data: action: allow oembed: config: | - oembed_enabled: true \ No newline at end of file + oembed_enabled: true + clamav-module: + config: | + modules: + - module: clamav_spam_checker.ClamAVSpamChecker + config: + clamd_host: "clamav.matrix.svc.cluster.local" + clamd_port: 3310 \ No newline at end of file diff --git a/apps/production/kustomization.yaml b/apps/production/kustomization.yaml index 335e91c..fe7b986 100644 --- a/apps/production/kustomization.yaml +++ b/apps/production/kustomization.yaml @@ -47,3 +47,17 @@ resources: - draupnir-secret.yaml - draupnir-pvc.yaml - draupnir.yaml + # ClamAV für Media-Scanning via Synapse-Modul (Issue #19) + - clamav-pvc.yaml + - clamav.yaml + +# Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted - +# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml +# referenziert wird (kustomize kann Referenzen nicht in opaken YAML-Strings umschreiben). +configMapGenerator: + - name: synapse-clamav-module + namespace: matrix + files: + - clamav_spam_checker.py + options: + disableNameSuffixHash: true diff --git a/apps/production/networkpolicy.yaml b/apps/production/networkpolicy.yaml index 645afec..a661f7e 100644 --- a/apps/production/networkpolicy.yaml +++ b/apps/production/networkpolicy.yaml @@ -303,3 +303,25 @@ spec: # Note: coturn runs with hostNetwork: true, so NetworkPolicy does not apply to it at all - # it's already gated by the Hetzner Cloud Firewall instead. Nothing to write here. +--- +# ClamAV (Issue #19): only Synapse's check_media_file_for_spam module calls this, over +# clamd's plain TCP protocol on port 3310. Nothing else needs to reach it. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-clamav + namespace: matrix +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: clamav + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: synapse-main + ports: + - protocol: TCP + port: clamd