Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
3 changed files with 209 additions and 12 deletions
Showing only changes of commit 676fbb8ee4 - Show all commits
+50 -5
View File
@@ -1,6 +1,6 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
@@ -43,6 +43,26 @@ const MAX_SEITEN = 30
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
const MAX_ZEICHEN = 400_000
/**
* Laengste Kante einer gerenderten Seite in Pixeln.
*
* Vorher wurde mit festen 300 dpi gerendert. Bei einer normalen A4-Seite
* sind das rund 2480 x 3510 Pixel - unproblematisch. Die Seitengroesse
* steht aber im PDF und kann beliebig gross angegeben werden: Bei einer
* Seite von 200 x 200 Zoll ergeben 300 dpi ein Bild von 60000 x 60000
* Pixeln, also mehrere Gigabyte auf der Platte, und das je Seite. Ein
* Dokument, das jemand in die Akte legt, haette so den Server vollschreiben
* koennen.
*
* "-scale-to" haengt die Aufloesung an die Pixelzahl statt an die
* angegebene Papiergroesse. 3500 Pixel entsprechen bei A4 etwa 300 dpi -
* fuer den Regelfall bleibt also alles wie vorher.
*/
const MAX_KANTE_PIXEL = 3500
/** Wie viel die gerenderten Seiten zusammen belegen duerfen. */
const MAX_BILDER_BYTES = 512 * 1024 * 1024
// -----------------------------------------------------------------------------
// Verfuegbarkeit
// -----------------------------------------------------------------------------
@@ -146,14 +166,39 @@ async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
await ausfuehren(
'pdftoppm',
['-r', '300', '-gray', '-png', '-l', String(MAX_SEITEN), pdfPfad, join(verzeichnis, 'seite')],
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, windowsHide: true },
[
// Statt fester dpi: Die laengste Kante wird auf eine feste Pixelzahl
// gebracht. Siehe MAX_KANTE_PIXEL - eine im PDF angegebene Riesenseite
// ergibt sonst ein Riesenbild.
'-scale-to',
String(MAX_KANTE_PIXEL),
'-gray',
'-png',
'-l',
String(MAX_SEITEN),
pdfPfad,
join(verzeichnis, 'seite'),
],
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, maxBuffer: 1024 * 1024, windowsHide: true },
)
const dateien = await readdir(verzeichnis)
return dateien
const dateien = (await readdir(verzeichnis))
.filter((name) => name.startsWith('seite') && name.endsWith('.png'))
.sort()
.map((name) => join(verzeichnis, name))
// Zweiter Deckel, hinterher: Was pdftoppm trotz -scale-to abgelegt hat,
// wird nur bis zu einer Gesamtgroesse weiterverarbeitet. Der Rest bleibt
// liegen und wird mit dem Arbeitsverzeichnis geloescht.
const genommen: string[] = []
let gesamt = 0
for (const pfad of dateien) {
const { size } = await stat(pfad)
if (gesamt + size > MAX_BILDER_BYTES) break
gesamt += size
genommen.push(pfad)
}
return genommen
}
/**
+55 -7
View File
@@ -15,8 +15,33 @@ import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
*
* Rein, ohne Seiteneffekte, ohne Netz.
*
* ## Grenzen, und warum es sie braucht
*
* Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird. Ein paar
* Kilobyte Nullen komprimieren auf fast nichts und blaehen sich beim
* Auspacken auf Gigabyte auf - die klassische Dekompressionsbombe. Vorher
* lief das Auspacken ohne Obergrenze, und ein Dokument, das jemand in die
* Akte legt, haette den Server damit aus dem Speicher werfen koennen.
*
* Deshalb drei Deckel: einer je Strom, einer fuer die Summe, und die
* bestehende Obergrenze von 2000 Stroemen. Wird einer erreicht, bricht das
* Auspacken ab und der Text ist eben unvollstaendig - besser ein
* "nicht_verfuegbar" als ein Serverneustart.
*/
/**
* Wie gross ein einzelner ausgepackter Strom werden darf.
*
* Acht Megabyte sind fuer eine Seite Text absurd viel - ein
* Behoerdenbescheid liegt bei einigen Kilobyte. Die Grenze ist bewusst
* grosszuegig: Sie soll Bomben abfangen, nicht ungewoehnliche PDFs.
*/
const MAX_STROM_BYTES = 8 * 1024 * 1024
/** Und wie viel es ueber alle Stroeme zusammen sein darf. */
const MAX_GESAMT_BYTES = 64 * 1024 * 1024
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string {
let ergebnis = ''
@@ -108,8 +133,9 @@ function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1')
const stroeme: string[] = []
let suchAb = 0
let gesamt = 0
while (stroeme.length < 2000) {
while (stroeme.length < 2000 && gesamt < MAX_GESAMT_BYTES) {
const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break
@@ -131,28 +157,50 @@ function stroemeAuspacken(pdf: Buffer): string[] {
}
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
continue
}
if (!/\/Filter/.test(kopf)) {
// Unkomprimiert: Hier gibt es nichts, was sich aufblaehen koennte -
// die Bytes stehen so in der Datei. Sie zaehlen trotzdem mit, damit
// die Summe stimmt.
gesamt += daten.length
stroeme.push(daten.toString('latin1'))
continue
}
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
}
return stroeme
}
function flateVersuchen(daten: Buffer): Buffer | null {
/**
* Packt einen Strom aus - mit Deckel.
*
* maxOutputLength laesst zlib abbrechen, sobald die Ausgabe die Grenze
* ueberschreitet; der Fehler landet im catch wie jeder andere. Ohne diese
* Angabe entpackt Node so lange, wie Speicher da ist.
*
* @param rest Wie viele Bytes ueber alle Stroeme noch uebrig sind.
*/
function flateVersuchen(daten: Buffer, rest: number): Buffer | null {
const grenze = Math.min(MAX_STROM_BYTES, rest)
if (grenze <= 0) return null
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try {
return fn(daten)
return fn(daten, { maxOutputLength: grenze })
} catch {
// naechster Versuch
}
+104
View File
@@ -0,0 +1,104 @@
import { deflateSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import { pdfTextLesen } from '@/lib/ocr/pdf-text'
/**
* Was passiert, wenn jemand ein praepariertes PDF in die Akte legt?
*
* Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird. Ein paar
* Kilobyte Nullen komprimieren auf fast nichts und blaehen sich beim
* Auspacken auf Gigabyte auf - die klassische Dekompressionsbombe. Der
* Textleser packte vorher ohne Obergrenze aus.
*
* Der Test rechnet nicht mit Gigabyte - das waere in einer Testsuite
* unhoeflich. Er baut eine Bombe, die die eingebaute Grenze knapp
* ueberschreitet, und besteht darauf, dass sie nicht ausgepackt wird.
*/
/** Baut ein minimales PDF mit einem einzigen Flate-Strom. */
function pdfMitStrom(nutzlast: Buffer, filter = '/FlateDecode'): Buffer {
const kopf = Buffer.from(
`%PDF-1.4\n1 0 obj\n<< /Length ${nutzlast.length} /Filter ${filter} >>\nstream\n`,
'latin1',
)
const fuss = Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1')
return Buffer.concat([kopf, nutzlast, fuss])
}
/**
* Text, den der Extraktor tatsaechlich findet: Er springt nur an, wenn im
* Strom PDF-Textoperatoren stehen.
*/
function inhaltsstrom(text: string): Buffer {
return Buffer.from(`BT /F1 12 Tf (${text}) Tj ET\n`, 'latin1')
}
describe('PDF-Textleser gegen praeparierte Dateien', () => {
it('liest einen gewoehnlichen Flate-Strom', () => {
const pdf = pdfMitStrom(deflateSync(inhaltsstrom('Bescheid vom Sozialamt')))
expect(pdfTextLesen(pdf)).toContain('Bescheid vom Sozialamt')
})
it('packt eine Bombe nicht aus, die die Stromgrenze ueberschreitet', () => {
// Die Nutzlast traegt eine erkennbare Marke UND 9 MB Fuellung. Beides
// ist wichtig: Ohne die Marke waere das Ergebnis auch ohne Grenze leer,
// und der Test bewiese nichts. Die Fuellung komprimiert auf wenige
// Kilobyte, die Grenze je Strom liegt bei 8 MB - zlib bricht ab, der
// Strom wird verworfen, und die Marke erscheint eben nicht.
const bombe = deflateSync(
Buffer.concat([inhaltsstrom('BOMBE-MARKE'), Buffer.alloc(9 * 1024 * 1024, 0x20)]),
)
const pdf = pdfMitStrom(bombe)
// Kein Absturz, kein Aufblaehen: einfach kein Text.
expect(pdfTextLesen(pdf)).toBe('')
})
it('liest denselben Strom, wenn er unter der Grenze bleibt', () => {
// Gegenprobe zum Test darueber: Dieselbe Bauform, nur 4 MB Fuellung.
// Damit ist belegt, dass oben die Grenze greift und nicht die Bauform
// des Testfalls.
const knappDrunter = deflateSync(
Buffer.concat([inhaltsstrom('BOMBE-MARKE'), Buffer.alloc(4 * 1024 * 1024, 0x20)]),
)
expect(pdfTextLesen(pdfMitStrom(knappDrunter))).toContain('BOMBE-MARKE')
})
it('bleibt handhabbar, wenn viele mittelgrosse Stroeme zusammenkommen', () => {
// Zwoelf Stroeme mit je 6 MB Nutzlast liegen einzeln unter der
// Stromgrenze, zusammen aber ueber der Gesamtgrenze von 64 MB. Ohne den
// zweiten Deckel waeren das 72 MB im Speicher, und zwar je Dokument.
const einer = deflateSync(Buffer.concat([inhaltsstrom('Seite'), Buffer.alloc(6 * 1024 * 1024, 0x20)]))
const teile: Buffer[] = [Buffer.from('%PDF-1.4\n', 'latin1')]
for (let i = 0; i < 12; i += 1) {
teile.push(
Buffer.from(`${i + 1} 0 obj\n<< /Length ${einer.length} /Filter /FlateDecode >>\nstream\n`, 'latin1'),
einer,
Buffer.from('\nendstream\nendobj\n', 'latin1'),
)
}
teile.push(Buffer.from('%%EOF\n', 'latin1'))
const text = pdfTextLesen(Buffer.concat(teile))
// Etwas wird gelesen - aber nicht alle zwoelf Stroeme. Waere der
// Gesamtdeckel weg, stuende "Seite" zwoelfmal darin.
const treffer = text.split('Seite').length - 1
expect(treffer).toBeGreaterThan(0)
expect(treffer).toBeLessThan(12)
})
it('kommt mit einem kaputten Strom zurecht, ohne zu werfen', () => {
const pdf = pdfMitStrom(Buffer.from('das ist kein zlib-strom', 'latin1'))
expect(() => pdfTextLesen(pdf)).not.toThrow()
})
it('ignoriert Bilddaten, statt sie auszupacken zu versuchen', () => {
const pdf = pdfMitStrom(deflateSync(inhaltsstrom('unsichtbar')), '/DCTDecode')
expect(pdfTextLesen(pdf)).toBe('')
})
})