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:
1 parent
bb8bf3714f
commit
de9cb09bcd
7 files changed
+345
-33
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',
|
||||
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',
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user