feat: real media antivirus scanning via custom Synapse module (Issue #19)
Auto-Deploy on Push / verify-and-notify (push) Canceled after 0s
Auto-Deploy on Push / verify-and-notify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: clamav-data
|
||||||
|
namespace: matrix
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 3Gi
|
||||||
@@ -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
|
||||||
@@ -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: <name> 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: <signature name> FOUND"
|
||||||
|
if text.endswith("FOUND"):
|
||||||
|
return text.removeprefix("stream:").removesuffix("FOUND").strip()
|
||||||
|
return None
|
||||||
@@ -10,6 +10,22 @@ data:
|
|||||||
rootLevel: INFO
|
rootLevel: INFO
|
||||||
levelOverrides:
|
levelOverrides:
|
||||||
synapse.media.url_previewer: DEBUG
|
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:
|
additional:
|
||||||
url-previews:
|
url-previews:
|
||||||
config: |
|
config: |
|
||||||
@@ -60,4 +76,11 @@ data:
|
|||||||
action: allow
|
action: allow
|
||||||
oembed:
|
oembed:
|
||||||
config: |
|
config: |
|
||||||
oembed_enabled: true
|
oembed_enabled: true
|
||||||
|
clamav-module:
|
||||||
|
config: |
|
||||||
|
modules:
|
||||||
|
- module: clamav_spam_checker.ClamAVSpamChecker
|
||||||
|
config:
|
||||||
|
clamd_host: "clamav.matrix.svc.cluster.local"
|
||||||
|
clamd_port: 3310
|
||||||
@@ -47,3 +47,17 @@ resources:
|
|||||||
- draupnir-secret.yaml
|
- draupnir-secret.yaml
|
||||||
- draupnir-pvc.yaml
|
- draupnir-pvc.yaml
|
||||||
- draupnir.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
|
||||||
|
|||||||
@@ -303,3 +303,25 @@ spec:
|
|||||||
|
|
||||||
# Note: coturn runs with hostNetwork: true, so NetworkPolicy does not apply to it at all -
|
# 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.
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user