Files
paperlessmanager/paperless-backend/src/common/pdf-dekompression.util.spec.ts
T
bjoernpoettkerandClaude Opus 5.5 de9cb09bcd 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>
2026-11-04 10:46:43 +01:00

91 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});