Skip to content

ClamAV-Antivirus-Gateway — Implementation

Status: alle drei Phasen LIVE seit 2026-05-09

  • Phase 1 — Scan-Pipeline + DMS/Mein-Fach/Space-File-Hooks + Admin-UI ✓
  • Phase 2 — SHA-256-Whitelist pro Tenant, Schul-Admin-Workflow ✓
  • Phase 3 — Quarantäne statt Sofort-Delete, Forensik-Download, 90d-Retention ✓

Anwender-Doku (für Schul-Sekretariat): siehe Virenschutz im Schul-Alltag.

ClamAV scannt jeden Upload (DMS, Mein Fach, Space-Datei, Personal-Fach-Drop) bevor er in der DB als Dokument registriert wird. Bei einem Treffer wandert die Datei in einen Quarantäne-Pfad, ein Audit-Eintrag in av_incidents wird geschrieben, und der Web-Client zeigt einen klaren Toast mit der Virus-Signatur.

Architektur

Drei Komponenten:

                    ┌────────────────────────┐
                    │  prilog-clamav         │
                    │  cpx22 (4GB), nbg1     │
                    │  Tailscale 100.64.x.x  │
                    │                        │
                    │  Docker:               │
                    │  clamav/clamav:stable  │
                    │  → 0.0.0.0:3310        │
                    │  → freshclam täglich   │
                    └───────────▲────────────┘

                       Tailscale│Mesh, port 3310

   ┌────────────────────────────┴──────────────────────────────┐
   │  Backend-API (shared-1)                                   │
   │                                                           │
   │  CLAMD_HOST=100.64.174.15  CLAMD_PORT=3310                │
   │                                                           │
   │  lib/clamav.ts ────────► scanBuffer (INSTREAM-Protocol)   │
   │  services/av-scan ─────► scanS3Object (Glue zu MinIO)     │
   │  services/av-incident ─► recordAvIncident, listAvIncidents│
   │                                                           │
   │  Upload-Routes (4):                                       │
   │  - DMS Document confirm-upload                            │
   │  - DMS Document version confirm                           │
   │  - Space-Datei confirm-upload                             │
   │  - Space-Datei version confirm                            │
   │  + bestehend: Personal-Fach (war schon vorher integriert) │
   │                                                           │
   │  Bei Treffer: HTTP 422 + details.signature                │
   └───────────────────────────────────────────────────────────┘


   ┌───────────────────────────────────────────────────────────┐
   │  Web-Client                                               │
   │                                                           │
   │  core/upload/upload-error.ts → showUploadError(err)       │
   │  Bei 422+signature:                                       │
   │     toast.error('🛡️ Malware erkannt — Datei abgelehnt    │
   │                 (Signatur: Eicar-Test-Signature)')        │
   └───────────────────────────────────────────────────────────┘

Warum dedizierter Server statt shared-1-Container?

ClamAV ist ein stateful Service:

  • 2 GB RAM konstant für die Signatur-DB (3.6 Mio Signaturen aus daily.cvd
    • main.cvd in-memory)
  • CPU-Spikes bei jedem Scan (50–100 MB/s pro Core)
  • Update-Last jede Nacht durch freshclam (~100 MB Download, In-Memory-Reload)

Pattern-Konsistenz mit Whisper (eigener Server für ML-Modell + GPU-Last). Wenn das Backend-API auf shared-1 unter Last steht, sollen ClamAV-Spikes das nicht beeinflussen — und umgekehrt. Migration zu eigenem Server kostet ~4€/Monat (cpx22) und ist die saubere Lösung.

Provisionierung

Server wurde am 2026-05-09 provisioniert:

Name:        prilog-clamav
Hetzner-ID:  130012871
Type:        cpx22 (2 vCPU AMD, 4 GB RAM)
Location:    nbg1 (Nürnberg)
Public-IP:   91.98.230.246
Tailscale:   100.64.174.15 (Hostname: prilog-clamav)
Firewall:    SSH:22 + ICMP von Internet erreichbar,
             Port 3310 nur von Tailscale-Range 100.64.0.0/10

Cloud-init installiert: Docker + Tailscale (mit Auth-Key) + ClamAV-Container mit network_mode: host und --restart=unless-stopped. Initial-Signatur-Update via freshclam läuft beim ersten Start (~3-5 Min), danach automatisch 4× täglich.

Datenmodell

av_incidents (Migration 0066):

SpalteTypBeschreibung
idbigserialPK
tenant_idvarchar(64)Tenant-ID für Multi-Tenant-Filter
user_matrix_idvarchar(255)?wer hat hochgeladen
filenamevarchar(500)Dateiname
virus_signaturevarchar(255)ClamAV-Signatur, z.B. Eicar-Test-Signature
contextvarchar(30)dms / mein-fach / space-file / chat / other
size_bytesbigint?Datei-Größe (vor Löschung)
mimetypevarchar(100)?MIME-Type
sha256varchar(64)?Hash (falls vorhanden, für Whitelist-Lookup)
blockedbooleanTRUE = Datei wurde abgewiesen, FALSE = warning-only
quarantine_keyvarchar(500)?Falls Quarantäne-Storage genutzt wird (heute leer)
detected_attimestamptzZeitpunkt des Treffers

Indizes: tenant+detected_at, user, signature, context.

Failopen-Default

Wenn clamd nicht erreichbar ist (Tailscale down, Container neu startet, Wartung), läuft das System failopen: Uploads werden akzeptiert, ohne Scan-Garantie. Die Logs zeigen [scan] uebersprungen: clamd offline.

Begründung: lieber Upload akzeptieren als gar nicht ankommen. Schul-Alltag hat einen niedrigen Threat-Level (überwiegend Klassen-PDFs, Eltern-Fotos), und ein 30-Sekunden-clamd-Restart soll nicht den ganzen Schulbetrieb blockieren.

Bei strikteren Anforderungen kann das Verhalten umgekehrt werden: CLAMAV_FAIL_MODE=closed — dann gibt's bei clamd-Ausfall HTTP 503 statt clean-Pass. Heute: failopen.

Smoke-Test

Aus dem /var/www/backend-api-Verzeichnis auf shared-1:

js
// eicar-test.mjs
import { scanBuffer, isClamdAvailable } from "./dist/lib/clamav.js";

const EICAR = Buffer.from('X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*');
const CLEAN = Buffer.from('Hallo, das ist eine harmlose Textdatei.');

console.log('clamd available:', await isClamdAvailable());
console.log('EICAR scan:', JSON.stringify(await scanBuffer(EICAR)));
console.log('clean scan:', JSON.stringify(await scanBuffer(CLEAN)));

Erwartete Ausgabe:

clamd available: true
EICAR scan: {"scanned":true,"clean":false,"signature":"Eicar-Test-Signature"}
clean scan: {"scanned":true,"clean":true}

EICAR ist die Standard-Test-Signatur — eine harmlose Zeichenfolge die jeder Virus-Scanner als Treffer erkennen muss.

Live-Test im Browser

  1. EICAR-Datei runterladen (https://secure.eicar.org/eicar.com)
  2. Im Web-Client zu Mein Fach oder zu einem Space mit Datei-Upload
  3. Datei ins Drop-Target ziehen
  4. Toast erscheint: 🛡️ Malware erkannt — Datei abgelehnt (Eicar-Test-Signature)
  5. Datei taucht nicht in der Liste auf
  6. Im Admin: /av-incidents zeigt den Eintrag mit Tenant, User, Kontext, Zeitstempel

Was die Pipeline NICHT macht

  • Synapse-Direct-Media-Uploads (legacy chat upload via /_matrix/media/v3/upload) laufen nicht durch den Backend-Scan, weil sie direkt zu Synapse gehen. Variante A aus dem Original-Plan (nginx Pre-Upload-Hook) ist möglich, aber durch die DMS-Unification wandern Chat-Anhänge zunehmend ins DMS — der Synapse-Direct-Pfad wird verdrängt.

  • Bestehende Daten vor dem 2026-05-09 wurden nicht nachträglich gescannt. Bei Bedarf: Scan-Cron, der alle Document und FileItem-Records iteriert und scannt. Heute nicht implementiert.

  • Whitelist für False-Positives. Wenn ein Upload fälschlich als infiziert markiert wird (selten, aber möglich bei Heuristik-Treffern), gibt's heute keinen UI-Knopf zum Whitelisten. Workaround: ClamAV-Signatur in clamd.conf als ignored aufnehmen, Container neu starten.

Operational

  • clamd-Status: Admin-UI /av-incidents zeigt oben rechts grünen Schild bei Online, roten bei Offline.
  • Container-Restart: ssh root@91.98.230.246 'docker restart clamav'
  • Signature-Update manuell: passiert automatisch alle 6h, manuell: ssh root@91.98.230.246 'docker exec clamav freshclam'
  • Logs: ssh root@91.98.230.246 'docker logs clamav --tail 100'
  • Skalierung: bei >100 Tenants oder hoher Upload-Frequenz: zweite ClamAV-Instanz hinter HAProxy, oder größeren Hetzner (cpx32, 8 GB RAM, 4 vCPU). Aktuell cpx22 reicht problemlos für 50+ Tenants.

Phase 2 (LIVE 2026-05-09): SHA-256-Whitelist pro Tenant

Tenant-Admins können bei einem False-Positive den Hash der Datei whitelisten:

  • Migration 0067 av_whitelist (tenantId + sha256 unique, plus filename, reason, createdBy, useCount, lastUsedAt)
  • Vor dem clamd-Scan: SHA-256 server-side berechnen, Whitelist-Lookup. Bei Match: Scan überspringen + Audit-Eintrag mit blocked=false
  • Workspace-API: GET/POST/DELETE /workspace/av-whitelist
  • Web-Client (Settings → Sicherheit): zwei neue Blöcke. Bei einem geblockten Vorfall mit sha256 → Button "Whitelisten" mit Begründungs-Prompt

Phase 3 (LIVE 2026-05-09): Quarantäne statt Sofort-Delete

Statt infizierte Dateien direkt zu löschen, werden sie in einen Quarantäne-Pfad verschoben. Tenant-Admin kann sie zur Forensik runterladen oder endgültig löschen.

  • moveToQuarantine(tenantId, sourceKey) in object-storage.service: S3-CopyObject in quarantine/<ts>_<orig-key> + DeleteObject vom Original. Atomare Move-Semantik
  • av_incidents.quarantine_key wird beim Treffer mit dem neuen Key gefüllt
  • Workspace-Endpoints:
    • GET /workspace/av-incidents/:id/quarantine-download?reason=… — Forensik-Download mit Pflicht-Begründung (>=5 Zeichen). Audit-Eintrag mit FORENSIC_DOWNLOAD: <reason>
    • DELETE /workspace/av-incidents/:id/quarantine — endgültige Löschung. Audit bleibt
  • Web-Client: zwei neue Icon-Buttons pro Vorfall (Download + Endgültig löschen)
  • Cron av-quarantine-retention täglich 04:15 UTC: löscht Quarantäne-Dateien älter als 90 Tage. Audit bleibt

Lifecycle einer infizierten Datei:

Upload → Scan → Treffer

moveToQuarantine: <orig-key> → quarantine/<ts>_<orig-key>

av_incidents.quarantine_key = <neuer Pfad>

[bis zu 90 Tage]

Optionen:
  a) Tenant-Admin: Download (Forensik, Audit)
  b) Tenant-Admin: Endgültig löschen
  c) Cron: automatischer Cleanup nach 90 Tagen

Folge-Stufen (geplant)

  • Phase 4: Cron-basierter Re-Scan aller bestehenden Dokumente bei großem Signatur-Update (z.B. neue CVE-Disclosure)
  • Phase 5: Synapse-Media-Direct-Pfad mit nginx Pre-Hook absichern (oder DMS-Unification verdrängt diesen Pfad komplett)
  • Restore-Workflow für False-Positives (heute: Whitelist + manueller Re-Upload). Saubere Lösung: Datei aus Quarantäne in einen recovered/-Pfad verschieben + automatischen Whitelist-Eintrag