Files
Harlekin 4652dd08ec
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Virenpruefung an clamd anbinden, wo clamd tatsaechlich lauscht
Die Adresse zeigte auf /var/run/clamav/clamd.sock in einem geteilten
Volume. Das offizielle clamav-Image legt seinen Unix-Socket aber unter
/tmp im eigenen Container an - nachgemessen, das Verzeichnis im Volume
bleibt leer. Jeder Verbindungsversuch lief damit in ENOENT, und die
Statusseite meldete die Virenpruefung dauerhaft als gestoert.

Folgenreich war das nicht nur fuer die Anzeige: ohne erreichbaren Scanner
bleiben Uploads auf "Pruefung unvollstaendig" liegen und brauchen die
Freigabe von Hand. Die Kette hat also gehalten, aber niemand kam durch.

Dasselbe Image oeffnet ab Werk TCPSocket 3310. Darueber laeuft die
Anbindung jetzt; der Port ist im Stack nicht veroeffentlicht und nur im
internen Netz erreichbar. CLAMAV_ADDRESS nimmt weiterhin einen Pfad an,
falls irgendwo doch ein Unix-Socket erreichbar ist.

Das Socket-Volume faellt weg, es enthielt nie etwas. Der Scanner sieht
ausserdem die Dateiablage nicht mehr: uebertragen wird per INSTREAM durch
die Verbindung, gelesen hat er dort nie etwas.

Geprueft gegen ein laufendes clamd, einschliesslich EICAR. Diese Pruefung
bleibt als abschaltbarer Teil der Suite stehen:

  CLAMAV_LIVE=clamav:3310 pnpm --filter @afis/web test pruefkette

Die uebrigen neuen Pruefungen decken das Zerlegen der Adresse ab. Sie
haetten den Fehler nicht gefunden - eine Adresse, die auf nichts zeigt,
ist syntaktisch tadellos. Nur eine echte Verbindung beantwortet das.
2026-09-04 15:04:51 +02:00

226 lines
7.6 KiB
TypeScript

import { access, readFile, writeFile } from 'node:fs/promises'
import { db } from '@/lib/db'
import { getEnv } from '@/lib/env'
import { scanBuffer } from '@/lib/files/clamav'
import { absolutePath, ensureDirFor, sha256Of, STORE, storageKeyFor } from '@/lib/files/paths'
import { detectType, matchesDeclared } from '@/lib/files/magic'
import { reencode } from '@/lib/files/reencode'
import { caseTopic, notifyAttachmentScanned, userTopic, type Topic } from '@/lib/realtime/publish'
/**
* Die Prüfkette, in der Reihenfolge aus docs/07-dateien-und-sicherheit.md:
*
* quarantine/ → Typprüfung → ClamAV → neu kodieren → store/
*
* Sie läuft **nach** der Antwort auf den Upload. Der Nutzer sieht den Anhang
* sofort mit dem Zustand „Wird geprüft"; kein Fortschrittsbalken wartet auf
* einen Virenscanner. Siehe docs/09-performance-und-ux.md.
*
* Jeder Schritt kann nur in eine Richtung ausgehen: was nicht ausdrücklich
* besteht, bleibt in der Quarantäne. Es gibt keinen Pfad, auf dem eine Datei
* ungeprüft in `store/` landet.
*/
export async function runCheckChain(
attachmentId: string,
options: {
/**
* Nur für die bewusste Freigabe durch einen Administrator, wenn der
* Scanner nicht erreichbar war. Die Typprüfung und die Neukodierung laufen
* auch dann - sie sind der eigentliche Schutz.
*/
skipScan?: boolean
} = {},
): Promise<void> {
const anhang = await db.attachment.findUnique({
where: { id: attachmentId },
select: {
id: true,
storageKey: true,
mimeType: true,
scanStatus: true,
uploadedById: true,
caseId: true,
entryId: true,
documentId: true,
},
})
if (!anhang || anhang.scanStatus !== 'PENDING') return
const thema = await themaVon(anhang)
try {
const inhalt = new Uint8Array(await readFile(absolutePath(anhang.storageKey)))
// 1. Typprüfung. Der gemeldete Typ steht bis hierher in mimeType.
const erkannt = detectType(inhalt)
if (!erkannt.ok) {
return blockieren(attachmentId, typFehler(erkannt.reason), thema)
}
if (!matchesDeclared(erkannt.type, anhang.mimeType)) {
return blockieren(
attachmentId,
`Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`,
thema,
)
}
// 2. Virenscanner.
if (!options.skipScan) {
const scan = await scanBuffer(getEnv().CLAMAV_ADDRESS, inhalt)
if (!scan.ok) {
// Nicht blockiert, sondern unentschieden: die Datei ist nicht als böse
// erkannt, sie ist ungeprüft. Ein Administrator kann sie freigeben.
return abbrechen(attachmentId, scan.reason, thema)
}
if (!scan.clean) {
return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`, thema)
}
}
// 3. Neu kodieren. Hier verliert die Datei alles, was nicht Inhalt ist.
const neu = await reencode(inhalt, erkannt.type)
if (!neu.ok) {
if (!options.skipScan) return abbrechen(attachmentId, neu.reason, thema)
// Bei der Freigabe von Hand bleibt die Datei so, wie sie ist. Das ist
// die Entscheidung, die der Administrator trifft, und sie steht danach
// im Zustand der Datei.
return freigeben(attachmentId, erkannt.type, { content: inhalt }, thema, {
hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`,
})
}
// 4. Freigeben.
await freigeben(attachmentId, erkannt.type, neu.result, thema, {
hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined,
})
} catch (error) {
await abbrechen(
attachmentId,
error instanceof Error ? error.message : 'Prüfung fehlgeschlagen',
thema,
)
}
}
function typFehler(reason: 'unknown-type' | 'not-utf8' | 'empty'): string {
if (reason === 'empty') return 'Die Datei ist leer'
if (reason === 'not-utf8') return 'Kein gültiges UTF-8'
return 'Dateityp nicht erlaubt. Zugelassen sind PNG, JPEG, WebP, PDF und Text'
}
type Fertig = {
content: Uint8Array
width?: number
height?: number
thumb?: Uint8Array
blurhash?: string
}
/**
* Wen das Ergebnis der Prüfung angeht: die Akte, in der die Datei hängt, sonst
* der Hochladende selbst. Ohne Thema erführe niemand vom Ausgang, mit dem
* falschen erführen es die Falschen.
*/
async function themaVon(anhang: {
uploadedById: string
caseId: string | null
entryId: string | null
documentId: string | null
}): Promise<Topic> {
if (anhang.caseId) return caseTopic(anhang.caseId)
if (anhang.entryId) {
const eintrag = await db.caseEntry.findUnique({
where: { id: anhang.entryId },
select: { caseId: true },
})
if (eintrag) return caseTopic(eintrag.caseId)
}
if (anhang.documentId) {
const doc = await db.document.findUnique({
where: { id: anhang.documentId },
select: { caseId: true },
})
if (doc?.caseId) return caseTopic(doc.caseId)
}
return userTopic(anhang.uploadedById)
}
async function freigeben(
attachmentId: string,
type: string,
neu: Fertig,
thema: Topic,
options: { hinweis?: string } = {},
): Promise<void> {
const sha256 = sha256Of(neu.content)
// Gleicher Inhalt schon da: nur verweisen, nicht ein zweites Mal ablegen.
//
// Der Verweis gilt aber nur, wenn die Datei auch wirklich noch liegt. Eine
// Zeile in der Datenbank ist kein Beweis dafür: geht die Ablage verloren -
// ein geleertes Verzeichnis, ein nicht eingehängtes Volume -, zeigten sonst
// alle künftigen Uploads desselben Inhalts stumm ins Leere.
const vorhanden = await db.attachment.findFirst({
where: { sha256, scanStatus: 'CLEAN', id: { not: attachmentId } },
select: { storageKey: true, thumbKey: true, blurhash: true, width: true, height: true },
})
const wiederverwendbar = vorhanden !== null && (await liegtDa(vorhanden.storageKey))
const storageKey = wiederverwendbar ? vorhanden!.storageKey : storageKeyFor(STORE, attachmentId)
let thumbKey = wiederverwendbar ? vorhanden!.thumbKey : null
if (!wiederverwendbar) {
await writeFile(await ensureDirFor(storageKey), neu.content)
if (neu.thumb) {
thumbKey = `${storageKey}.thumb`
await writeFile(await ensureDirFor(thumbKey), neu.thumb)
}
}
await db.attachment.update({
where: { id: attachmentId },
data: {
scanStatus: 'CLEAN',
scanMessage: options.hinweis ?? null,
mimeType: type,
sizeBytes: neu.content.byteLength,
sha256,
storageKey,
thumbKey,
blurhash: neu.blurhash ?? (wiederverwendbar ? vorhanden!.blurhash : null),
width: neu.width ?? (wiederverwendbar ? vorhanden!.width : null),
height: neu.height ?? (wiederverwendbar ? vorhanden!.height : null),
},
})
await notifyAttachmentScanned(attachmentId, thema)
}
/** Liegt die Datei zu diesem Schlüssel wirklich noch da? */
async function liegtDa(storageKey: string): Promise<boolean> {
try {
await access(absolutePath(storageKey))
return true
} catch {
return false
}
}
/** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */
async function blockieren(attachmentId: string, grund: string, thema: Topic): Promise<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'BLOCKED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId, thema)
}
/** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */
async function abbrechen(attachmentId: string, grund: string, thema: Topic): Promise<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'FAILED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId, thema)
}