Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
+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