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

255 lines
9.2 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { readFile } from 'node:fs/promises'
import sharp from 'sharp'
import { db } from '@/lib/db'
import { acceptUpload, loadDeliverable, releaseAttachment } from '@/lib/files/service'
import { runCheckChain } from '@/lib/files/process'
import { parseClamavAddress, pingClamav, scanBuffer } from '@/lib/files/clamav'
import { absolutePath } from '@/lib/files/paths'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die ganze Kette, an einer echten Datei.
*
* Auf dem Entwicklungsrechner läuft kein clamav. Genau das macht den Test
* wertvoll: er hält fest, dass ein fehlender Scanner eine Datei **nicht**
* sauber macht. Wäre das je anders, stünde hier ein roter Test.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
describe('Adresse des Virenscanners', () => {
/**
* Der Grund für diese Prüfungen: die Adresse stand als Pfad auf ein
* geteiltes Volume in der Konfiguration, das offizielle clamav-Image legt
* seinen Unix-Socket aber unter /tmp im eigenen Container an. Das
* Verzeichnis blieb leer, jeder Verbindungsversuch lief in ENOENT, und die
* Virenprüfung meldete sich dauerhaft als gestört. Ein Tippfehler in der
* Adresse darf nicht wieder still enden.
*/
it('liest host:port', () => {
expect(parseClamavAddress('clamav:3310')).toEqual({ host: 'clamav', port: 3310 })
expect(parseClamavAddress(' 127.0.0.1:3310 ')).toEqual({ host: '127.0.0.1', port: 3310 })
})
it('lässt eine IPv6-Adresse heil', () => {
expect(parseClamavAddress('[::1]:3310')).toEqual({ host: '::1', port: 3310 })
})
it('erkennt einen Unix-Socket am Pfad', () => {
expect(parseClamavAddress('/tmp/clamd.sock')).toEqual({ path: '/tmp/clamd.sock' })
})
it('wirft bei Unsinn, statt still ins Leere zu greifen', () => {
for (const eingabe of ['clamav', 'clamav:', ':3310', 'clamav:0', 'clamav:99999', '']) {
expect(() => parseClamavAddress(eingabe), eingabe).toThrow()
}
})
it('meldet einen nicht erreichbaren Scanner als nicht erreichbar', async () => {
// Port 1 ist reserviert und lauscht nirgends. Kein Absturz, ein `false`.
expect(await pingClamav('127.0.0.1:1', 1000)).toBe(false)
expect(await pingClamav('/gibt/es/nicht.sock', 1000)).toBe(false)
})
})
/**
* Gegen einen laufenden Scanner. Standardmässig übersprungen, weil auf dem
* Entwicklungsrechner keiner läuft.
*
* Diese Prüfungen hätten den Fehler gefunden, den die obigen nicht finden
* konnten: eine Adresse, die auf nichts zeigt, ist syntaktisch tadellos. Nur
* eine echte Verbindung beantwortet die Frage, ob die Prüfkette wirklich hält.
*
* CLAMAV_LIVE=clamav:3310 pnpm --filter @afis/web test pruefkette
*/
const live = process.env.CLAMAV_LIVE
const liveSuite = live ? describe : describe.skip
liveSuite('Gegen einen laufenden Scanner', () => {
it('antwortet auf die Anfrage', async () => {
expect(await pingClamav(live!, 5000)).toBe(true)
})
it('lässt harmlosen Inhalt durch', async () => {
expect(await scanBuffer(live!, Buffer.from('harmloser Text'))).toEqual({
ok: true,
clean: true,
})
})
it('erkennt die Testsignatur', async () => {
// EICAR ist kein Schadprogramm, sondern die vereinbarte Zeichenfolge, an
// der jeder Scanner anschlagen muss. Damit lässt sich prüfen, dass die
// Kette wirklich prüft und nicht nur eine Verbindung aufbaut.
const eicar = 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
expect(await scanBuffer(live!, Buffer.from(eicar))).toEqual({
ok: true,
clean: false,
signature: 'Eicar-Test-Signature',
})
})
})
const marke = `kette-${Date.now()}`
function perms(
userId: string,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel: 4,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Prüfkette', () => {
const nutzer = perms(`${marke}-nutzer`, { permissions: ['entry.create', 'case.edit'] })
const admin = perms(`${marke}-admin`, { isAdmin: true })
let caseNumber = ''
async function bild(): Promise<Uint8Array> {
const buffer = await sharp({
create: { width: 20, height: 20, channels: 3, background: { r: 10, g: 90, b: 200 } },
})
.jpeg()
.withMetadata({ exif: { IFD0: { Copyright: 'VERTRAULICHE QUELLE' } } })
.toBuffer()
return new Uint8Array(buffer)
}
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const p of [nutzer, admin]) {
await db.user.create({ data: { id: p.userId, username: p.userId, displayName: p.userId } })
}
const akte = await db.case.create({
data: {
caseNumber: `${marke}`.slice(0, 40),
title: 'Anhangprüfung',
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.userId,
classification: 1,
status: 'OPEN',
},
})
caseNumber = akte.caseNumber
})
afterAll(async () => {
await db.$disconnect()
})
it('nimmt die Datei an und legt sie in der Quarantäne ab', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'urlaubsbild.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('PENDING')
expect(anhang.storageKey).toMatch(/^quarantine\//)
// Der Dateiname des Nutzers taucht im Pfad nicht auf.
expect(anhang.storageKey).not.toContain('urlaubsbild')
await expect(readFile(absolutePath(anhang.storageKey))).resolves.toBeDefined()
// Und ausgeliefert wird sie nicht, solange sie dort liegt.
expect(await loadDeliverable(nutzer, id)).toBeNull()
})
it('macht aus einem fehlenden Scanner keine saubere Datei', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'zweites.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
// Ohne clamd: unentschieden, nicht sauber und nicht blockiert.
expect(anhang.scanStatus).toBe('FAILED')
expect(anhang.scanMessage).toBeTruthy()
expect(await loadDeliverable(admin, id)).toBeNull()
})
it('blockiert einen Typ, der nicht auf der Liste steht', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'archiv.zip',
declaredType: 'application/zip',
content: new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
// Endgültig: das ist kein "wissen wir nicht", das ist ein Nein.
expect(anhang.scanStatus).toBe('BLOCKED')
expect(anhang.scanMessage).toContain('nicht erlaubt')
})
it('blockiert, wenn der gemeldete Typ nicht zum Inhalt passt', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'getarnt.pdf',
declaredType: 'application/pdf',
content: await bild(),
})
await runCheckChain(id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('BLOCKED')
})
it('gibt eine unvollständige Prüfung nur für Admins frei, und kodiert dabei neu', async () => {
const { id } = await acceptUpload(nutzer, {
owner: { kind: 'case', caseNumber },
fileName: 'freizugeben.jpg',
declaredType: 'image/jpeg',
content: await bild(),
})
await runCheckChain(id)
await expect(releaseAttachment(nutzer, id)).rejects.toThrow(AccessError)
await releaseAttachment(admin, id)
const anhang = await db.attachment.findUniqueOrThrow({ where: { id } })
expect(anhang.scanStatus).toBe('CLEAN')
expect(anhang.storageKey).toMatch(/^store\//)
expect(anhang.releasedById).toBe(admin.userId)
// Die Freigabe überspringt nur den Scanner. Die Neukodierung lief mit:
// die Metadaten des Bildes sind weg.
expect(anhang.scanMessage).toContain('Ohne Virenprüfung')
const abgelegt = await readFile(absolutePath(anhang.storageKey))
expect(abgelegt.includes(Buffer.from('VERTRAULICHE QUELLE'))).toBe(false)
expect(anhang.thumbKey).toBeTruthy()
expect(anhang.blurhash).toBeTruthy()
expect(anhang.width).toBe(20)
// Jetzt, und erst jetzt, ist sie abrufbar.
expect(await loadDeliverable(nutzer, id)).not.toBeNull()
})
it('lässt eine blockierte Datei auch vom Admin nicht freigeben', async () => {
const blockiert = await db.attachment.findFirstOrThrow({
where: { uploadedById: nutzer.userId, scanStatus: 'BLOCKED' },
select: { id: true },
})
await expect(releaseAttachment(admin, blockiert.id)).rejects.toThrow(AccessError)
})
})