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
105 lines
4.1 KiB
TypeScript
105 lines
4.1 KiB
TypeScript
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('')
|
|
})
|
|
})
|