diff --git a/src/lib/ocr/index.ts b/src/lib/ocr/index.ts index b9decfb..30a1c1f 100644 --- a/src/lib/ocr/index.ts +++ b/src/lib/ocr/index.ts @@ -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 { 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 } /** diff --git a/src/lib/ocr/pdf-text.ts b/src/lib/ocr/pdf-text.ts index f6c765a..2981cc2 100644 --- a/src/lib/ocr/pdf-text.ts +++ b/src/lib/ocr/pdf-text.ts @@ -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 } diff --git a/tests/pdf-bombe.test.ts b/tests/pdf-bombe.test.ts new file mode 100644 index 0000000..b053614 --- /dev/null +++ b/tests/pdf-bombe.test.ts @@ -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('') + }) +})