Texterkennung in Grenzen zwingen, die das Dokument nicht selbst setzt
Zwei Wege, auf denen ein hochgeladenes Dokument den Server umbringen konnte. Beide haben gemeinsam, dass die Groesse aus der Datei kam. 1. Dekompressionsbombe. Die PDF-Stroeme wurden mit inflateSync ohne maxOutputLength ausgepackt. Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird - ein paar Kilobyte Fuellung blaehen sich auf Gigabyte auf. Jetzt ein Deckel je Strom (8 MB) und einer ueber die Summe (64 MB); zlib bricht selbst ab, der Strom wird verworfen, der Text ist eben unvollstaendig. 2. Riesenseiten. "pdftoppm -r 300" rendert mit fester Aufloesung, aber die Seitengroesse steht im PDF. Bei 200 x 200 Zoll ergeben 300 dpi ein Bild von 60000 x 60000 Pixeln, und zwar je Seite. Jetzt "-scale-to" statt fester dpi: Die laengste Kante bekommt eine feste Pixelzahl, unabhaengig davon, was im Dokument als Papierformat steht. Fuer A4 entspricht das etwa den bisherigen 300 dpi. Dazu ein Deckel auf die Gesamtgroesse der gerenderten Dateien. Der erste Bombentest war wertlos: Die Nutzlast enthielt keine PDF-Textoperatoren, also war das Ergebnis mit und ohne Grenze leer. Mit einer Marke in der Nutzlast und einer Gegenprobe knapp unter der Grenze schlagen beide Mutationen an. Refs #38
This commit is contained in:
+50
-5
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user