# 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 Twisted's networking primitives - NOT asyncio's open_connection/ # wait_for. Synapse runs on Twisted's reactor, which does not have a running asyncio event # loop underneath it, so raw asyncio socket calls fail immediately with # "RuntimeError: no running event loop" (confirmed live, 2026-07-29 - see git history for # the asyncio-based version that failed this way). Twisted Deferreds are natively awaitable # from an `async def` when Synapse wraps the callback via Deferred.fromCoroutine(), so this # stays plain async/await from the caller's perspective. # # 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 logging from typing import Any, Union from twisted.internet import reactor from twisted.internet.defer import Deferred, TimeoutError as TwistedTimeoutError from twisted.internet.endpoints import HostnameEndpoint, connectProtocol from twisted.internet.protocol import Protocol 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 _ClamdInstreamProtocol(Protocol): """Speaks clamd's INSTREAM protocol for a single scan, then closes.""" def __init__(self, data: bytes, result: "Deferred[bytes]"): self._data = data self._result = result self._buffer = bytearray() def connectionMade(self) -> None: self.transport.write(b"zINSTREAM\0") for offset in range(0, len(self._data), CHUNK_SIZE): chunk = self._data[offset : offset + CHUNK_SIZE] self.transport.write(len(chunk).to_bytes(4, "big") + chunk) self.transport.write((0).to_bytes(4, "big")) def dataReceived(self, data: bytes) -> None: self._buffer.extend(data) if self._buffer.endswith(b"\0") or self._buffer.endswith(b"\n"): self.transport.loseConnection() def connectionLost(self, reason: Any = None) -> None: if not self._result.called: self._result.callback(bytes(self._buffer)) 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 self._scan(bytes(buffer)) 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.""" result: "Deferred[bytes]" = Deferred() endpoint = HostnameEndpoint(reactor, self.clamd_host.encode(), self.clamd_port) await connectProtocol(endpoint, _ClamdInstreamProtocol(data, result)) result.addTimeout(CLAMD_TIMEOUT_SECONDS, reactor) try: response = await result except TwistedTimeoutError: raise TimeoutError( f"clamd at {self.clamd_host}:{self.clamd_port} did not respond in time" ) 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