From 32e2c8e5566afc5c4ac5149c15cbf1520d2988db Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Wed, 29 Jul 2026 16:25:16 +0200 Subject: [PATCH] feat: deploy client-side ClamAV scan service for encrypted rooms Synapse's own media-scanning module (Issue #19) can never see E2EE attachment content - a structural limit, not a bug. This adds a small standalone HTTP wrapper around the same ClamAV instance, reachable from browser JS at /_scan, so the ThreadNet-Web client fork can scan plaintext both before encrypting/uploading and after downloading/ decrypting - covering both directions regardless of room encryption. Auth via Synapse's own /whoami endpoint, no separate auth system. --- apps/production/apex-ingress.yaml | 8 + .../production/clamav-http-scanner-Dockerfile | 8 + apps/production/clamav-http-scanner.py | 148 ++++++++++++++++++ apps/production/clamav-http-scanner.yaml | 61 ++++++++ apps/production/kustomization.yaml | 2 + apps/production/networkpolicy.yaml | 34 ++++ 6 files changed, 261 insertions(+) create mode 100644 apps/production/clamav-http-scanner-Dockerfile create mode 100644 apps/production/clamav-http-scanner.py create mode 100644 apps/production/clamav-http-scanner.yaml diff --git a/apps/production/apex-ingress.yaml b/apps/production/apex-ingress.yaml index 5dd0861..95de534 100644 --- a/apps/production/apex-ingress.yaml +++ b/apps/production/apex-ingress.yaml @@ -35,6 +35,14 @@ spec: services: - name: element-web-docs port: 80 + # Client-seitiger ClamAV-Scan-Dienst (Issue #19-Erweiterung: Scanning auch für + # verschlüsselte Räume, direkt vom Browser aus aufgerufen) + - match: Host(`axion1337.chat`) && PathPrefix(`/_scan`) + kind: Rule + priority: 50 + services: + - name: clamav-http-scanner + port: 8090 # Niedrigere Priorität: alles andere -> Element Web - match: Host(`axion1337.chat`) kind: Rule diff --git a/apps/production/clamav-http-scanner-Dockerfile b/apps/production/clamav-http-scanner-Dockerfile new file mode 100644 index 0000000..667e482 --- /dev/null +++ b/apps/production/clamav-http-scanner-Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.13-slim + +COPY clamav-http-scanner.py /app/clamav-http-scanner.py + +USER nobody +EXPOSE 8090 + +CMD ["python3", "/app/clamav-http-scanner.py"] diff --git a/apps/production/clamav-http-scanner.py b/apps/production/clamav-http-scanner.py new file mode 100644 index 0000000..cf4b3aa --- /dev/null +++ b/apps/production/clamav-http-scanner.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# Standalone HTTP wrapper around ClamAV's clamd, reachable from browser JS (unlike clamd's raw +# TCP protocol). Used by the ThreadNet-Web client fork to scan files client-side, both before +# upload (plaintext, pre-encryption) and after download+decrypt of E2EE attachments - the two +# places Synapse's own check_media_file_for_spam module (Issue #19) can never see, since +# Synapse never holds the room's decryption key. +# +# Talks to clamd via its native 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 bytes +# 3. send a zero-length chunk to signal end of stream +# 4. read the reply: "stream: OK" (clean) or "stream: FOUND" (infected) +# +# Stdlib only, synchronous/threaded (ThreadingHTTPServer) - no asyncio/Twisted constraints +# here since this runs as its own plain process, unlike the Synapse module. +# +# Auth: requires "Authorization: Bearer ", validated against Synapse's +# own /_matrix/client/v3/account/whoami - reuses Synapse's existing auth rather than building +# a new one, and stops this becoming an open "test your malware against our AV" oracle for +# anyone on the internet. This is a hard failure (401) - unlike scan errors below, this is an +# abuse-prevention concern, not a reliability one. +# +# Fails open on clamd connection errors (treats the file as clean, logs loudly) - matches the +# same fail-open design as the Synapse module, so a ClamAV hiccup doesn't block all uploads/ +# downloads site-wide. + +import json +import logging +import os +import socket +import sys +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +logging.basicConfig(level=logging.INFO, stream=sys.stdout) +logger = logging.getLogger("clamav-http-scanner") + +CLAMD_HOST = os.environ["CLAMD_HOST"] +CLAMD_PORT = int(os.environ["CLAMD_PORT"]) +SYNAPSE_WHOAMI_URL = os.environ["SYNAPSE_WHOAMI_URL"] +CLAMD_TIMEOUT_SECONDS = 30 +MAX_BODY_BYTES = 100 * 1024 * 1024 # 100MB, matches typical Synapse upload size limits + + +def check_auth(authorization_header: "str | None") -> bool: + if not authorization_header or not authorization_header.startswith("Bearer "): + return False + token = authorization_header.removeprefix("Bearer ").strip() + request = urllib.request.Request( + SYNAPSE_WHOAMI_URL, headers={"Authorization": f"Bearer {token}"} + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status == 200 + except urllib.error.URLError: + return False + + +def scan_bytes(data: bytes) -> "str | None": + """Returns the detected signature name, or None if clean. Raises on connection errors.""" + with socket.create_connection( + (CLAMD_HOST, CLAMD_PORT), timeout=CLAMD_TIMEOUT_SECONDS + ) as sock: + sock.sendall(b"zINSTREAM\0") + chunk_size = 2**14 + for offset in range(0, len(data), chunk_size): + chunk = data[offset : offset + chunk_size] + sock.sendall(len(chunk).to_bytes(4, "big") + chunk) + sock.sendall((0).to_bytes(4, "big")) + + response = b"" + while True: + part = sock.recv(4096) + if not part: + break + response += part + + text = response.decode("utf-8", errors="replace").strip("\x00 \n") + if text.endswith("FOUND"): + return text.removeprefix("stream:").removesuffix("FOUND").strip() + return None + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + logger.info("%s - %s", self.address_string(), format % args) + + def _send_json(self, status: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.end_headers() + + def do_POST(self) -> None: + # matches the ingress PathPrefix(`/_scan`) route as-is (Traefik doesn't strip the + # prefix by default) - keep client-facing and internal path identical. + if self.path != "/_scan": + self._send_json(404, {"error": "not found"}) + return + + if not check_auth(self.headers.get("Authorization")): + self._send_json(401, {"error": "invalid or missing access token"}) + return + + length = int(self.headers.get("Content-Length", 0)) + if length <= 0 or length > MAX_BODY_BYTES: + self._send_json(400, {"error": "missing or oversized body"}) + return + data = self.rfile.read(length) + + try: + signature = scan_bytes(data) + except OSError: + logger.exception( + "ClamAV scan failed (clamd at %s:%s unreachable?) - " + "treating file as clean (fail-open)", + CLAMD_HOST, + CLAMD_PORT, + ) + self._send_json(200, {"clean": True, "scan_error": "scanner_unavailable"}) + return + + if signature is None: + self._send_json(200, {"clean": True}) + else: + logger.warning("ClamAV flagged an upload/download: %s", signature) + self._send_json(200, {"clean": False, "signature": signature}) + + +def main() -> None: + server = ThreadingHTTPServer(("0.0.0.0", 8090), Handler) + logger.info("Listening on :8090, clamd=%s:%s", CLAMD_HOST, CLAMD_PORT) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/apps/production/clamav-http-scanner.yaml b/apps/production/clamav-http-scanner.yaml new file mode 100644 index 0000000..49d36d2 --- /dev/null +++ b/apps/production/clamav-http-scanner.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: Service +metadata: + name: clamav-http-scanner + namespace: matrix +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: clamav-http-scanner + ports: + - name: http + port: 8090 + protocol: TCP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: clamav-http-scanner + namespace: matrix +spec: + replicas: 1 + strategy: + type: RollingUpdate + selector: + matchLabels: + app.kubernetes.io/name: clamav-http-scanner + template: + metadata: + labels: + app.kubernetes.io/name: clamav-http-scanner + spec: + containers: + - name: clamav-http-scanner + image: rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8090 + env: + - name: CLAMD_HOST + value: "clamav.matrix.svc.cluster.local" + - name: CLAMD_PORT + value: "3310" + - name: SYNAPSE_WHOAMI_URL + value: "http://matrix-stack-synapse.matrix.svc.cluster.local:8008/_matrix/client/v3/account/whoami" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/apps/production/kustomization.yaml b/apps/production/kustomization.yaml index fe7b986..c50e8e1 100644 --- a/apps/production/kustomization.yaml +++ b/apps/production/kustomization.yaml @@ -50,6 +50,8 @@ resources: # ClamAV für Media-Scanning via Synapse-Modul (Issue #19) - clamav-pvc.yaml - clamav.yaml + # Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung) + - clamav-http-scanner.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 diff --git a/apps/production/networkpolicy.yaml b/apps/production/networkpolicy.yaml index a661f7e..65c8e8a 100644 --- a/apps/production/networkpolicy.yaml +++ b/apps/production/networkpolicy.yaml @@ -118,6 +118,15 @@ spec: ports: - protocol: TCP port: haproxy-synapse + # Client-Scan-Dienst (Issue #19-Erweiterung) validiert Access-Tokens gegen Synapses + # eigenen /whoami-Endpoint statt eine eigene Auth zu bauen. + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: clamav-http-scanner + ports: + - protocol: TCP + port: haproxy-synapse --- # account.axion1337.chat (Traefik) + matrix.axion1337.chat (also routes to MAS for some # paths) + synapse-main calling MAS's internal port for session/token introspection. @@ -322,6 +331,31 @@ spec: - podSelector: matchLabels: app.kubernetes.io/name: synapse-main + # Client-seitiger Scan-Dienst (Issue #19-Erweiterung) braucht denselben ClamAV. + - podSelector: + matchLabels: + app.kubernetes.io/name: clamav-http-scanner ports: - protocol: TCP port: clamd +--- +# axion1337.chat/_scan (Traefik) - client-seitiger Scan-Dienst, direkt vom Browser aufgerufen. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-clamav-http-scanner + namespace: matrix +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: clamav-http-scanner + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: http