From de9cb09bcd72b77734e262e68eaf1206e0c91574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 4 Nov 2026 08:18:26 +0100 Subject: [PATCH] fix(security): Ghostscript mit -dSAFER, Schutz vor PDF-Dekompressionsbomben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ghostscript läuft auf PDFs aus Mails und vom Scanner jetzt mit -dSAFER statt -dNOSAFER (kein Datei-/Prozesszugriff aus PostScript), mit Timeout (60 s je Seite, 10 min je Dokument, SIGKILL) und höchstens 500 gerenderten Seiten. Temp-Dateien werden auch im Fehlerfall entfernt. Ungenutztes getPageCount (brauchte Dateizugriff) entfernt. - Neuer Vorab-Check ueberschreitetDekompressionsLimit (256 MB, streamend, erkennt auch Deflate-Ketten und abgeschnittene Streams). Fingerprint (pdf.js) und E-Rechnungs-Extraktion (pdf-lib) laden Bomben nicht mehr; eine einzelne Mail konnte das Backend sonst per OOM dauerhaft lahmlegen. - Verschlüsselte PDFs bekommen keinen Inhalts-Fingerprint mehr ('' = nicht vergleichbar), weil pdf.js sie erst intern entschlüsselt. Co-Authored-By: Claude Opus 5.5 --- .../src/common/pdf-dekompression.util.spec.ts | 90 +++++++++++++ .../src/common/pdf-dekompression.util.ts | 127 ++++++++++++++++++ .../src/email/pdf-fingerprint.bombe.spec.ts | 31 +++++ .../src/email/pdf-fingerprint.util.ts | 16 ++- .../src/erechnung/pdf-attachment.util.spec.ts | 21 +++ .../src/erechnung/pdf-attachment.util.ts | 4 + .../src/preprocessing/pdf.service.ts | 109 +++++++++------ 7 files changed, 355 insertions(+), 43 deletions(-) create mode 100644 paperless-backend/src/common/pdf-dekompression.util.spec.ts create mode 100644 paperless-backend/src/common/pdf-dekompression.util.ts create mode 100644 paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts diff --git a/paperless-backend/src/common/pdf-dekompression.util.spec.ts b/paperless-backend/src/common/pdf-dekompression.util.spec.ts new file mode 100644 index 0000000..62337da --- /dev/null +++ b/paperless-backend/src/common/pdf-dekompression.util.spec.ts @@ -0,0 +1,90 @@ +import * as zlib from 'zlib'; +import { PDFDocument } from 'pdf-lib'; +import { + istVerschluesselt, + ueberschreitetDekompressionsLimit, +} from './pdf-dekompression.util'; + +const MB = 1024 * 1024; + +/** Minimales PDF-Gerüst mit einem Stream – für den Scanner reicht das. */ +function pdfMitStream(daten: Buffer, filter = '/FlateDecode'): Buffer { + return Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${daten.length} /Filter ${filter} >>\nstream\n`, + 'latin1', + ), + daten, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); +} + +describe('ueberschreitetDekompressionsLimit', () => { + it('lässt ein normales PDF durch', async () => { + const doc = await PDFDocument.create(); + doc.addPage().drawText('Rechnung 2026-0001'); + const pdf = Buffer.from(await doc.save()); + expect(await ueberschreitetDekompressionsLimit(pdf, 1 * MB)).toBe(false); + }); + + it('erkennt eine Deflate-Bombe (wenige KB → viele MB)', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(20 * MB)); + expect(bombe.length).toBeLessThan(100 * 1024); + expect( + await ueberschreitetDekompressionsLimit(pdfMitStream(bombe), 5 * MB), + ).toBe(true); + }); + + it('erkennt verschachtelte Deflate-Ketten', async () => { + const innen = zlib.deflateSync(Buffer.alloc(20 * MB)); + const aussen = zlib.deflateSync(innen); + const pdf = pdfMitStream(aussen, '[/FlateDecode /FlateDecode]'); + expect(await ueberschreitetDekompressionsLimit(pdf, 5 * MB)).toBe(true); + }); + + it('summiert über mehrere Streams', async () => { + const teil = zlib.deflateSync(Buffer.alloc(2 * MB)); + const pdf = Buffer.concat([1, 2, 3, 4].map(() => pdfMitStream(teil))); + expect(await ueberschreitetDekompressionsLimit(pdf, 5 * MB)).toBe(true); + expect(await ueberschreitetDekompressionsLimit(pdf, 10 * MB)).toBe(false); + }); + + it('zählt auch abgeschnittene Streams (pdf.js liest sie trotzdem)', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(20 * MB)); + const abgeschnitten = bombe.subarray(0, bombe.length - 10); + expect( + await ueberschreitetDekompressionsLimit( + pdfMitStream(abgeschnitten), + 5 * MB, + ), + ).toBe(true); + }); + + it('wertet nicht komprimierte Streams mit ihrer Rohgröße', async () => { + const roh = Buffer.alloc(2 * MB, 0x41); + expect( + await ueberschreitetDekompressionsLimit( + pdfMitStream(roh, '/DCTDecode'), + 1 * MB, + ), + ).toBe(true); + }); +}); + +describe('istVerschluesselt', () => { + it('erkennt ein /Encrypt-Dictionary im Trailer', () => { + const pdf = Buffer.from( + '%PDF-1.7\ntrailer\n<< /Encrypt 5 0 R >>\n', + 'latin1', + ); + expect(istVerschluesselt(pdf)).toBe(true); + }); + + it('ignoriert ähnliche Namen', () => { + const pdf = Buffer.from( + '%PDF-1.7\n<< /EncryptMetadata false >>\n', + 'latin1', + ); + expect(istVerschluesselt(pdf)).toBe(false); + }); +}); diff --git a/paperless-backend/src/common/pdf-dekompression.util.ts b/paperless-backend/src/common/pdf-dekompression.util.ts new file mode 100644 index 0000000..0531cef --- /dev/null +++ b/paperless-backend/src/common/pdf-dekompression.util.ts @@ -0,0 +1,127 @@ +import * as zlib from 'zlib'; + +/** + * Obergrenze für die Summe aller entpackten Streams eines PDFs, bevor es an + * pdf.js (Fingerprint) oder pdf-lib (E-Rechnung) geht. Beide entpacken ohne + * eigenes Limit; eine Deflate-Bombe (≈ 1:1000) aus einem unauthentifiziert + * zugestellten Mailanhang bringt sonst den Backend-Prozess zum OOM-Absturz. + */ +export const STANDARD_DEKOMPRESSIONS_LIMIT = 256 * 1024 * 1024; + +/** Ausgaben bis zu dieser Größe werden behalten, um Deflate-Ketten zu prüfen. */ +const MAX_KETTENPUFFER = 16 * 1024 * 1024; +const MAX_KETTENTIEFE = 4; + +/** + * Schätzt konservativ, ob das Entpacken aller Streams des PDFs das Limit + * überschreitet. Bewusst ohne PDF-Parser: Jeder Bereich zwischen `stream` und + * `endstream` wird – soweit zlib-Daten – gezählt entpackt, sonst mit seiner + * Rohgröße gewertet. Der Speicherbedarf bleibt dabei klein (Streaming). + * + * Verschlüsselte Streams sind hier nicht prüfbar – Aufrufer, deren Parser + * entschlüsselt (pdf.js), müssen zusätzlich {@link istVerschluesselt} prüfen. + */ +export async function ueberschreitetDekompressionsLimit( + pdf: Buffer, + limit = STANDARD_DEKOMPRESSIONS_LIMIT, +): Promise { + const text = pdf.toString('latin1'); + + let rest = limit; + let pos = 0; + for (;;) { + const fund = text.indexOf('stream', pos); + if (fund < 0) return false; + pos = fund + 'stream'.length; + // `endstream` enthält ebenfalls `stream` + if (text.startsWith('end', fund - 3)) continue; + + let start = pos; + if (text[start] === '\r') start++; + if (text[start] === '\n') start++; + if (start === pos) continue; // kein Stream-Schlüsselwort + + const ende = text.indexOf('endstream', start); + const stop = ende < 0 ? pdf.length : ende; + rest -= await entpackteGroesse(pdf.subarray(start, stop), rest + 1, 0); + if (rest < 0) return true; + pos = stop; + } +} + +/** Hat das PDF ein /Encrypt-Dictionary? */ +export function istVerschluesselt(pdf: Buffer): boolean { + return /\/Encrypt\b/.test(pdf.toString('latin1')); +} + +/** Entpackte Größe, höchstens `max` (dann gilt das Limit als gerissen). */ +async function entpackteGroesse( + daten: Buffer, + max: number, + tiefe: number, +): Promise { + if (!istZlib(daten)) return daten.length; + + const { groesse, ausgabe, ausgabeIstZlib } = await zaehleInflate(daten, max); + if (groesse >= max) return max; + if (tiefe >= MAX_KETTENTIEFE) return groesse; + + // Deflate-Kette (/Filter [/FlateDecode /FlateDecode]): innere Stufe prüfen. + if (ausgabe && istZlib(ausgabe)) { + return Math.max(groesse, await entpackteGroesse(ausgabe, max, tiefe + 1)); + } + // Zu große Ausgabe, die selbst wieder zlib ist – nicht weiter prüfbar. + if (ausgabeIstZlib) return max; + return groesse; +} + +function zaehleInflate( + daten: Buffer, + max: number, +): Promise<{ + groesse: number; + ausgabe: Buffer | null; + ausgabeIstZlib: boolean; +}> { + return new Promise((resolve) => { + const inflate = zlib.createInflate({ + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + const teile: Buffer[] = []; + let groesse = 0; + let behalten = true; + let erledigt = false; + let ausgabeIstZlib = false; + + const fertig = () => { + if (erledigt) return; + erledigt = true; + inflate.destroy(); + resolve({ + groesse, + ausgabe: behalten ? Buffer.concat(teile) : null, + ausgabeIstZlib, + }); + }; + + inflate.on('data', (chunk: Buffer) => { + if (groesse === 0) ausgabeIstZlib = istZlib(chunk); + groesse += chunk.length; + if (behalten && groesse <= MAX_KETTENPUFFER) teile.push(chunk); + else { + behalten = false; + teile.length = 0; + } + if (groesse >= max) fertig(); + }); + // Beschädigte Daten: pdf.js liest bis zum Fehler – genau so zählen. + inflate.on('error', fertig); + inflate.on('end', fertig); + inflate.end(daten); + }); +} + +/** zlib-Header: CMF 0x78, und (CMF·256 + FLG) durch 31 teilbar. */ +function istZlib(b: Buffer): boolean { + return b.length >= 2 && b[0] === 0x78 && ((b[0] << 8) | b[1]) % 31 === 0; +} diff --git a/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts b/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts new file mode 100644 index 0000000..81241ff --- /dev/null +++ b/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts @@ -0,0 +1,31 @@ +import * as zlib from 'zlib'; +import { PDFParse } from 'pdf-parse'; +import { computeContentFingerprint } from './pdf-fingerprint.util'; + +jest.mock('pdf-parse', () => ({ PDFParse: jest.fn() })); + +describe('computeContentFingerprint – ungeprüfte Inhalte', () => { + it('übergibt eine Deflate-Bombe nicht an pdf.js', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(300 * 1024 * 1024)); + const pdf = Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${bombe.length} /Filter /FlateDecode >>\nstream\n`, + 'latin1', + ), + bombe, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); + + await expect(computeContentFingerprint(pdf)).resolves.toBe(''); + expect(PDFParse).not.toHaveBeenCalled(); + }); + + it('übergibt verschlüsselte PDFs nicht an pdf.js', async () => { + const pdf = Buffer.from( + '%PDF-1.7\ntrailer\n<< /Encrypt 5 0 R >>\n%%EOF\n', + 'latin1', + ); + await expect(computeContentFingerprint(pdf)).resolves.toBe(''); + expect(PDFParse).not.toHaveBeenCalled(); + }); +}); diff --git a/paperless-backend/src/email/pdf-fingerprint.util.ts b/paperless-backend/src/email/pdf-fingerprint.util.ts index a5caf9f..4341a2f 100644 --- a/paperless-backend/src/email/pdf-fingerprint.util.ts +++ b/paperless-backend/src/email/pdf-fingerprint.util.ts @@ -1,5 +1,9 @@ import * as crypto from 'crypto'; import { PDFParse } from 'pdf-parse'; +import { + istVerschluesselt, + ueberschreitetDekompressionsLimit, +} from '../common/pdf-dekompression.util'; /** * Unterhalb dieser Textlänge gilt ein PDF als "ohne Textebene" (z.B. Scan) – @@ -15,11 +19,21 @@ const MIN_TEXT_LENGTH = 20; * * @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text * (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das - * PDF nicht gelesen werden konnte. + * PDF nicht gelesen werden konnte. Verschlüsselte PDFs und solche, + * die entpackt zu groß wären, liefern ebenfalls `''`. */ export async function computeContentFingerprint( pdf: Buffer, ): Promise { + // pdf.js entpackt ohne Limit – Dekompressionsbomben gar nicht erst laden. + // Verschlüsselte Streams entschlüsselt pdf.js erst intern; vorab sind sie + // nicht prüfbar. Beides gilt als "nicht vergleichbar" (''). + if ( + istVerschluesselt(pdf) || + (await ueberschreitetDekompressionsLimit(pdf)) + ) { + return ''; + } // pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben const parser = new PDFParse({ data: new Uint8Array(pdf) }); try { diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts index d104611..9f10cb6 100644 --- a/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts +++ b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts @@ -49,3 +49,24 @@ describe('extrahiereEingebetteteDateien', () => { expect(await extrahiereEingebetteteDateien(pdf)).toEqual([]); }); }); + +describe('extrahiereEingebetteteDateien – Dekompressionsbombe', () => { + it('lädt ein PDF mit Deflate-Bombe gar nicht erst', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const zlib = require('zlib') as typeof import('zlib'); + const bombe = zlib.deflateSync(Buffer.alloc(300 * 1024 * 1024)); + const pdf = Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${bombe.length} /Filter /FlateDecode >>\nstream\n`, + 'latin1', + ), + bombe, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); + const load = jest.spyOn(PDFDocument, 'load'); + + await expect(extrahiereEingebetteteDateien(pdf)).resolves.toEqual([]); + expect(load).not.toHaveBeenCalled(); + load.mockRestore(); + }); +}); diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.ts b/paperless-backend/src/erechnung/pdf-attachment.util.ts index 05c9c47..2ddcd7b 100644 --- a/paperless-backend/src/erechnung/pdf-attachment.util.ts +++ b/paperless-backend/src/erechnung/pdf-attachment.util.ts @@ -9,6 +9,7 @@ import { PDFString, decodePDFRawStream, } from 'pdf-lib'; +import { ueberschreitetDekompressionsLimit } from '../common/pdf-dekompression.util'; export interface EingebetteteDatei { name: string; @@ -30,6 +31,9 @@ const MAX_TIEFE = 32; export async function extrahiereEingebetteteDateien( pdf: Buffer, ): Promise { + // pdf-lib entpackt Objekt- und Datei-Streams ohne Limit. + if (await ueberschreitetDekompressionsLimit(pdf)) return []; + const doc = await PDFDocument.load(pdf, { ignoreEncryption: true, throwOnInvalidObject: false, diff --git a/paperless-backend/src/preprocessing/pdf.service.ts b/paperless-backend/src/preprocessing/pdf.service.ts index 2baf0f0..c40ad48 100644 --- a/paperless-backend/src/preprocessing/pdf.service.ts +++ b/paperless-backend/src/preprocessing/pdf.service.ts @@ -7,6 +7,27 @@ import * as os from 'os'; const execFileAsync = promisify(execFile); +/** + * Ghostscript verarbeitet PDFs aus Mails und vom Scanner – also Eingaben + * Unbekannter. Deshalb immer mit -dSAFER (kein Dateisystem-/Prozesszugriff aus + * PostScript heraus) und mit harter Zeit- und Seitengrenze. + */ +const GS_BASIS_ARGS = ['-dSAFER', '-dNOPAUSE', '-dBATCH', '-q']; + +/** Höchstens so viele Seiten werden gerendert (Schutz vor Seitenbomben). */ +export const MAX_RENDER_SEITEN = 500; + +const GS_TIMEOUT_EINE_SEITE_MS = 60_000; +const GS_TIMEOUT_DOKUMENT_MS = 10 * 60_000; + +function gs(args: string[], timeoutMs: number) { + return execFileAsync('gs', [...GS_BASIS_ARGS, ...args], { + timeout: timeoutMs, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }); +} + @Injectable() export class PdfService { private readonly logger = new Logger(PdfService.name); @@ -19,17 +40,22 @@ export class PdfService { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); const outputPath = path.join(tmpDir, `page-${page}.png`); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=png16m', - `-dFirstPage=${page}`, - `-dLastPage=${page}`, - `-r${dpi}`, - `-sOutputFile=${outputPath}`, - pdfPath, - ]); + try { + await gs( + [ + '-sDEVICE=png16m', + `-dFirstPage=${page}`, + `-dLastPage=${page}`, + `-r${dpi}`, + `-sOutputFile=${outputPath}`, + pdfPath, + ], + GS_TIMEOUT_EINE_SEITE_MS, + ); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }); + throw err; + } this.logger.debug(`PDF Seite ${page} konvertiert: ${outputPath}`); return outputPath; @@ -48,15 +74,21 @@ export class PdfService { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=png16m', - `-r${dpi}`, - `-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`, - pdfPath, - ]); + try { + await gs( + [ + '-sDEVICE=png16m', + `-dLastPage=${MAX_RENDER_SEITEN}`, + `-r${dpi}`, + `-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`, + pdfPath, + ], + GS_TIMEOUT_DOKUMENT_MS, + ); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }); + throw err; + } const entries = await fs.readdir(tmpDir); const images = entries @@ -68,6 +100,12 @@ export class PdfService { }) .map((f) => path.join(tmpDir, f)); + if (images.length >= MAX_RENDER_SEITEN) { + this.logger.warn( + `PDF hat mehr als ${MAX_RENDER_SEITEN} Seiten, nur die ersten werden gerendert: ${pdfPath}`, + ); + } + if (images.length === 0) { this.logger.warn( `Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`, @@ -81,34 +119,21 @@ export class PdfService { return images; } - /** - * Ermittelt die Seitenanzahl einer PDF via Ghostscript. - */ - async getPageCount(pdfPath: string): Promise { - const { stdout } = await execFileAsync('gs', [ - '-q', - '-dNODISPLAY', - '-dNOSAFER', - `-c`, - `(${pdfPath.replace(/\\/g, '/')}) (r) file runpdfbegin pdfpagecount = quit`, - ]); - return parseInt(stdout.trim(), 10) || 1; - } - /** * Bereinigt eine PDF (entschlüsselt sie ggf. wenn nur Owner-Passwort gesetzt ist) * via Ghostscript pdfwrite. */ async sanitizePdf(inputPath: string): Promise { const outputPath = path.join(os.tmpdir(), `sanitized-${Date.now()}.pdf`); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=pdfwrite', - `-sOutputFile=${outputPath}`, - inputPath, - ]); + try { + await gs( + ['-sDEVICE=pdfwrite', `-sOutputFile=${outputPath}`, inputPath], + GS_TIMEOUT_DOKUMENT_MS, + ); + } catch (err) { + await fs.rm(outputPath, { force: true }); + throw err; + } return outputPath; }