Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
+50
-5
@@ -1,6 +1,6 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import { execFile } from 'node:child_process'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
@@ -43,6 +43,26 @@ const MAX_SEITEN = 30
|
|||||||
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
|
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
|
||||||
const MAX_ZEICHEN = 400_000
|
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
|
// Verfuegbarkeit
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -146,14 +166,39 @@ async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?
|
|||||||
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
|
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
|
||||||
await ausfuehren(
|
await ausfuehren(
|
||||||
'pdftoppm',
|
'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'))
|
.filter((name) => name.startsWith('seite') && name.endsWith('.png'))
|
||||||
.sort()
|
.sort()
|
||||||
.map((name) => join(verzeichnis, name))
|
.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.
|
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
|
||||||
*
|
*
|
||||||
* Rein, ohne Seiteneffekte, ohne Netz.
|
* 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. */
|
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
|
||||||
function literalEntpacken(roh: string): string {
|
function literalEntpacken(roh: string): string {
|
||||||
let ergebnis = ''
|
let ergebnis = ''
|
||||||
@@ -108,8 +133,9 @@ function stroemeAuspacken(pdf: Buffer): string[] {
|
|||||||
const roh = pdf.toString('latin1')
|
const roh = pdf.toString('latin1')
|
||||||
const stroeme: string[] = []
|
const stroeme: string[] = []
|
||||||
let suchAb = 0
|
let suchAb = 0
|
||||||
|
let gesamt = 0
|
||||||
|
|
||||||
while (stroeme.length < 2000) {
|
while (stroeme.length < 2000 && gesamt < MAX_GESAMT_BYTES) {
|
||||||
const beginn = roh.indexOf('stream', suchAb)
|
const beginn = roh.indexOf('stream', suchAb)
|
||||||
if (beginn < 0) break
|
if (beginn < 0) break
|
||||||
|
|
||||||
@@ -131,28 +157,50 @@ function stroemeAuspacken(pdf: Buffer): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
|
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
|
||||||
const entpackt = flateVersuchen(daten)
|
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
|
||||||
if (entpackt) stroeme.push(entpackt.toString('latin1'))
|
if (entpackt) {
|
||||||
|
gesamt += entpackt.length
|
||||||
|
stroeme.push(entpackt.toString('latin1'))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/\/Filter/.test(kopf)) {
|
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'))
|
stroeme.push(daten.toString('latin1'))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
|
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
|
||||||
const entpackt = flateVersuchen(daten)
|
const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
|
||||||
if (entpackt) stroeme.push(entpackt.toString('latin1'))
|
if (entpackt) {
|
||||||
|
gesamt += entpackt.length
|
||||||
|
stroeme.push(entpackt.toString('latin1'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stroeme
|
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]) {
|
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
|
||||||
try {
|
try {
|
||||||
return fn(daten)
|
return fn(daten, { maxOutputLength: grenze })
|
||||||
} catch {
|
} catch {
|
||||||
// naechster Versuch
|
// 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