Files
AfiS-Aktensystem/web/lib/files/process.ts
T
Harlekin 55bcb0d95c Phase 5 abgeschlossen: Dateien mit vollständiger Prüfkette
Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt
keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen
Schalter, der die Kette abschaltet.

Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus
file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert
erkennen zu können. Die Signatur muss an Position 0 stehen - ein Vorspann vor
%PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu
machen. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der
Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen.

Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst
"wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe
wiederholt die Kette ohne den Scanner - Typprüfung und Neukodierung laufen
dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält
das fest.

Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der
zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07.
Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert
wurden.

Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung
eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst.

/api/events liefert attachment.scanned über pg_notify - die Grundlage, auf der
Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft,
sonst verriete schon die Kennung die Existenz.

Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war
ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte
stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen.
Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen
Bereich mit.

133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e.
2026-09-02 20:27:22 +02:00

169 lines
5.7 KiB
TypeScript

import { 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 { notifyAttachmentScanned } 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 },
})
if (!anhang || anhang.scanStatus !== 'PENDING') return
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))
}
if (!matchesDeclared(erkannt.type, anhang.mimeType)) {
return blockieren(
attachmentId,
`Angekündigt als ${anhang.mimeType}, erkannt als ${erkannt.type}`,
)
}
// 2. Virenscanner.
if (!options.skipScan) {
const scan = await scanBuffer(getEnv().CLAMAV_SOCKET, 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)
}
if (!scan.clean) {
return blockieren(attachmentId, `Schadsoftware erkannt: ${scan.signature}`)
}
}
// 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)
// 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 }, {
hinweis: `Ohne Virenprüfung und ohne Neukodierung freigegeben: ${neu.reason}`,
})
}
// 4. Freigeben.
await freigeben(attachmentId, erkannt.type, neu.result, {
hinweis: options.skipScan ? 'Ohne Virenprüfung freigegeben' : undefined,
})
} catch (error) {
await abbrechen(
attachmentId,
error instanceof Error ? error.message : 'Prüfung fehlgeschlagen',
)
}
}
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
}
async function freigeben(
attachmentId: string,
type: string,
neu: Fertig,
options: { hinweis?: string } = {},
): Promise<void> {
const sha256 = sha256Of(neu.content)
// Gleicher Inhalt schon da: nur verweisen, nicht ein zweites Mal ablegen.
const vorhanden = await db.attachment.findFirst({
where: { sha256, scanStatus: 'CLEAN', id: { not: attachmentId } },
select: { storageKey: true, thumbKey: true, blurhash: true, width: true, height: true },
})
let storageKey = vorhanden?.storageKey ?? storageKeyFor(STORE, attachmentId)
let thumbKey = vorhanden?.thumbKey ?? null
if (!vorhanden) {
await writeFile(await ensureDirFor(storageKey), neu.content)
if (neu.thumb) {
thumbKey = `${storageKey}.thumb`
await writeFile(await ensureDirFor(thumbKey), neu.thumb)
}
} else {
storageKey = vorhanden.storageKey
}
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 ?? vorhanden?.blurhash ?? null,
width: neu.width ?? vorhanden?.width ?? null,
height: neu.height ?? vorhanden?.height ?? null,
},
})
await notifyAttachmentScanned(attachmentId)
}
/** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */
async function blockieren(attachmentId: string, grund: string): Promise<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'BLOCKED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId)
}
/** Unentschieden: die Prüfung konnte nicht abgeschlossen werden. */
async function abbrechen(attachmentId: string, grund: string): Promise<void> {
await db.attachment.update({
where: { id: attachmentId },
data: { scanStatus: 'FAILED', scanMessage: grund },
})
await notifyAttachmentScanned(attachmentId)
}