fix(security): Ghostscript mit -dSAFER, Schutz vor PDF-Dekompressionsbomben

- 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 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-04 10:46:43 +01:00
1 parent bb8bf3714f
commit de9cb09bcd
7 files changed
+355 -43

No files matched your search

@@ -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);
});
});