ADR: scan uploaded documents with ClamAV in the Domain Service, fail closed #190

Open
opened 2026-10-02 06:59:39 +00:00 by not · 0 comments
Contributor

Decision to be made: how uploaded diplomas are checked for malware before they are stored in OpenZaak, and where in the stack that check lives.

Context / forces: today a zorgprofessional's upload goes portal → BFF → Domain (ProvideDocuments) → ACL → OpenZaak with no check at all: not on content, not even on whether it is a PDF. Behandelaars later open these files. A scan needs a signature engine, which means a new service in compose and in the Helm chart (CLAUDE.md §14: new dependency, new boundary).

Options considered:

  1. Scan in the BFF. Earliest point, but the BFF is a thin proxy (§8.3); any other entry point to the domain would skip the scan.
  2. Scan in the ACL, just before ZGW storage. The ACL is an anti-corruption layer for ZGW only; malware policy is not a ZGW concern.
  3. Scan in the Domain Service behind an IDocumentScanner port (proposed). "Only a clean PDF is stored and unblocks beoordeling" is a business rule of the provide-documents use case. The adapter lives in Big.Infrastructure and talks to clamd (ClamAV daemon, official clamav/clamav image) over its INSTREAM TCP protocol.

Proposed option + why: option 3. The INSTREAM protocol is a few lines over TcpClient (send zINSTREAM\0, length-prefixed chunks, read stream: OK / … FOUND), so no NuGet package (nClam etc.) is added. Also check the %PDF- magic bytes, so a renamed executable is refused before it is scanned.

Consequences:

  • Fail closed: if clamd is unreachable the upload is refused (503), nothing is stored and the document wait is not completed. The user retries; we never store an unscanned file.
  • Infected file → 422 with a business message in the portal; nothing stored, process stays in WachtOpDocumenten.
  • clamd needs about 1.2–1.5 GB RAM once signatures are loaded, and freshclam downloads about 300 MB of signatures on first start. Watch the verify-stack memory ceiling (#182) and the 3-minute compose-up budget. Signatures go on a volume so a restart doesn't download them again. Set ConcurrentDatabaseReload no so a signature reload doesn't double the memory.
  • Synthetic tests use the EICAR test string; no real malware in the repo.

Coupling rules touched (CLAUDE.md §8): none. A new peer service is reached only through its documented protocol, and only from the Domain's Infrastructure layer.

On acceptance, the ADR file (docs/architecture/adr-0036-scan-uploads-with-clamav.md, Nygard template) lands in the PR that implements the infra slice.

**Decision to be made:** how uploaded diplomas are checked for malware before they are stored in OpenZaak, and where in the stack that check lives. **Context / forces:** today a zorgprofessional's upload goes portal → BFF → Domain (`ProvideDocuments`) → ACL → OpenZaak with no check at all: not on content, not even on whether it is a PDF. Behandelaars later open these files. A scan needs a signature engine, which means a new service in compose and in the Helm chart (CLAUDE.md §14: new dependency, new boundary). **Options considered:** 1. **Scan in the BFF.** Earliest point, but the BFF is a thin proxy (§8.3); any other entry point to the domain would skip the scan. 2. **Scan in the ACL, just before ZGW storage.** The ACL is an anti-corruption layer for ZGW only; malware policy is not a ZGW concern. 3. **Scan in the Domain Service behind an `IDocumentScanner` port (proposed).** "Only a clean PDF is stored and unblocks beoordeling" is a business rule of the provide-documents use case. The adapter lives in `Big.Infrastructure` and talks to **clamd** (ClamAV daemon, official `clamav/clamav` image) over its INSTREAM TCP protocol. **Proposed option + why:** option 3. The INSTREAM protocol is a few lines over `TcpClient` (send `zINSTREAM\0`, length-prefixed chunks, read `stream: OK` / `… FOUND`), so no NuGet package (nClam etc.) is added. Also check the `%PDF-` magic bytes, so a renamed executable is refused before it is scanned. **Consequences:** - **Fail closed:** if clamd is unreachable the upload is refused (503), nothing is stored and the document wait is not completed. The user retries; we never store an unscanned file. - Infected file → 422 with a business message in the portal; nothing stored, process stays in WachtOpDocumenten. - clamd needs about 1.2–1.5 GB RAM once signatures are loaded, and `freshclam` downloads about 300 MB of signatures on first start. Watch the verify-stack memory ceiling (#182) and the 3-minute compose-up budget. Signatures go on a volume so a restart doesn't download them again. Set `ConcurrentDatabaseReload no` so a signature reload doesn't double the memory. - Synthetic tests use the EICAR test string; no real malware in the repo. **Coupling rules touched (CLAUDE.md §8):** none. A new peer service is reached only through its documented protocol, and only from the Domain's Infrastructure layer. > On acceptance, the ADR file (`docs/architecture/adr-0036-scan-uploads-with-clamav.md`, Nygard template) lands in the PR that implements the infra slice.
not added this to the Iteration 6 — Production Posture milestone 2026-10-02 06:59:39 +00:00
not added the type:adr-proposalarea:domainarea:infra labels 2026-10-02 06:59:41 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: eho/register-referentie#190