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);
});
});
@@ -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<boolean> {
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<number> {
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;
}
@@ -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();
});
});
@@ -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<string | null> {
// 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 {
@@ -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();
});
});
@@ -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<EingebetteteDatei[]> {
// pdf-lib entpackt Objekt- und Datei-Streams ohne Limit.
if (await ueberschreitetDekompressionsLimit(pdf)) return [];
const doc = await PDFDocument.load(pdf, {
ignoreEncryption: true,
throwOnInvalidObject: false,
@@ -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<number> {
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<string> {
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;
}