Files
register-referentie/docs/architecture/adr-0036-scan-uploads-with-clamav.md
T
notandClaude Opus 5.5 f541254de7
CI / k8s (pull_request) Successful in 8s
CI / build (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m56s
CI / docs (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m21s
CI / frontend (pull_request) Successful in 2m22s
CI / mutation (pull_request) Successful in 4m47s
CI / verify-stack (pull_request) Skipped
docs(arch): ADR-0036 — scan uploads with ClamAV in the domain, fail closed (refs #191, refs #190)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:02:57 +02:00

3.2 KiB

ADR-0036: Uploaded documents are scanned by ClamAV in the Domain Service, fail closed

  • Status: Accepted
  • Date: 2026-10-02
  • Deciders: Respellion engineering
  • Slice: proposed in #190; clamd deployed in #191 (S-28), scanning wired in #192 (S-29).

Context

A zorgprofessional's diploma upload goes portal → BFF → Domain (ProvideDocuments) → ACL → OpenZaak. Nothing on that path looks at the file. It is not checked for malware, and nobody checks that it is a PDF. Behandelaars open these files later, so the register stores, and then serves, whatever a citizen sends.

Scanning needs a signature engine that stays up to date. That means a new peer service, and that makes it an ADR (CLAUDE.md §14).

Decision

  1. Engine: the ClamAV daemon (clamd), official image clamav/clamav, pinned tag, as its own service in compose and in the Helm chart. freshclam in the same container keeps the signatures current, and they live on a volume.
  2. Where the check lives: in the Domain Service, behind an IDocumentScanner port in Big.Application. "Only a clean PDF is stored and unblocks beoordeling" is a rule of the provide-documents use case. The BFF is a thin proxy (§8.3), and the ACL translates ZGW and nothing else (§8.1). A check in the domain also covers every entry point, not just the portal.
  3. Protocol: the adapter in Big.Infrastructure speaks clamd's INSTREAM protocol over TcpClient: zINSTREAM\0, length-prefixed chunks, a zero-length terminator, then a stream: OK or stream: <name> FOUND reply. That is a few lines of code, so we add no NuGet package for it (nClam and similar).
  4. Fail closed: if clamd can't be reached, the upload is refused (503). Nothing is stored and the document wait stays open. We never store an unscanned file.
  5. Type check: content must start with %PDF-. This refuses a renamed executable before the scan, and it costs nothing.

Consequences

  • One more long-running service. clamd holds its signatures in memory (about 1 GB idle). ConcurrentDatabaseReload no stops a signature reload from holding a second copy, but clamd pauses scans for the few seconds a reload takes. Compose caps it at mem_limit: 2g, and the chart requests 1200Mi. This counts against the verify-stack runner's memory ceiling (#182).
  • The first start downloads about 300 MB of signatures from the ClamAV CDN, so the runner and the cluster node need outbound internet (as seed-zaaktype already does). The CDN rate-limits by IP. A CI runner that starts fresh often can get throttled, and then the health check doesn't go green. If that happens, mirror the signatures (cvdupdate) rather than retrying.
  • Tests use the EICAR test string, built from two halves so the repo itself does not trip an on-access scanner. No real malware is ever committed.
  • Infected or non-PDF uploads are refused with 422 and a business message. We don't keep a quarantine copy: a refused file is simply not stored.