feat: deploy client-side ClamAV scan service for encrypted rooms
Auto-Deploy on Push / verify-and-notify (push) Successful in 53s
Auto-Deploy on Push / verify-and-notify (push) Successful in 53s
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.
This commit is contained in:
@@ -35,6 +35,14 @@ spec:
|
|||||||
services:
|
services:
|
||||||
- name: element-web-docs
|
- name: element-web-docs
|
||||||
port: 80
|
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
|
# Niedrigere Priorität: alles andere -> Element Web
|
||||||
- match: Host(`axion1337.chat`)
|
- match: Host(`axion1337.chat`)
|
||||||
kind: Rule
|
kind: Rule
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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: <name> 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 <matrix access token>", 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()
|
||||||
@@ -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
|
||||||
@@ -50,6 +50,8 @@ resources:
|
|||||||
# ClamAV für Media-Scanning via Synapse-Modul (Issue #19)
|
# ClamAV für Media-Scanning via Synapse-Modul (Issue #19)
|
||||||
- clamav-pvc.yaml
|
- clamav-pvc.yaml
|
||||||
- clamav.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 -
|
# Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted -
|
||||||
# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml
|
# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: haproxy-synapse
|
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
|
# 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.
|
# paths) + synapse-main calling MAS's internal port for session/token introspection.
|
||||||
@@ -322,6 +331,31 @@ spec:
|
|||||||
- podSelector:
|
- podSelector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: synapse-main
|
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:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: clamd
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user