Pentest 57.7 MEDIUM + 57.8 MEDIUM: Consent-Hash-TTL + Zip-Slip-Härtung

57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
  20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
  bekommen NOW()+30d als Default, damit frische Versand-Links nicht
  sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
  klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
  der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
  ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
  der Standard-Customer-Endpoint kein Workflow-Info leakt.

57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
  Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
  schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
  startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
  Limit; bei Überschreitung Abbruch mit klarer Meldung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:13:06 +02:00
parent a023e96012
commit 9482424ade
5 changed files with 97 additions and 15 deletions
+36 -5
View File
@@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
// Pentest 57.8 (2026-06-01): Mehrstufige Verteidigung gegen Path-
// Traversal-Varianten, die `path.resolve` allein eventuell durchlässt
// (z.B. Backslash-Mischformen auf Cross-OS, Null-Bytes, leere Namen,
// explizite `..`-Segmente). Plus Zip-Bomb-Schutz per Entry-Größenlimit.
const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true });
const MAX_ENTRY_SIZE = 500 * 1024 * 1024; // 500 MB pro Entry
let totalUncompressed = 0;
const MAX_TOTAL_UNCOMPRESSED = 5 * 1024 * 1024 * 1024; // 5 GB Gesamt
for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName;
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
// Reject: leer, Null-Byte, absoluter Pfad, Backslashes (Cross-OS-
// Confusion), expliziter `..`-Segment im Original-Namen,
// Home-Dir-Expansion `~/`.
if (
!entryName
|| entryName.includes('\0')
|| entryName.includes('\\')
|| entryName.startsWith('~')
|| path.isAbsolute(entryName)
|| entryName.split('/').some((seg) => seg === '..')
) {
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
}
const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
// Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
// liegen. path.relative gibt "../..." zurück wenn target außerhalb
// liegt das ist robuster als startsWith + Separator-Concat.
const rel = path.relative(absBackupDir, targetPath);
if (rel === '' && !entry.isDirectory) {
return { success: false, error: `Datei-Eintrag zeigt auf das Backup-Wurzelverzeichnis` };
}
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return {
success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
@@ -1063,6 +1084,16 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Zip-Bomb-Schutz: Entry-Größe begrenzen und Gesamt-Tracking
if (typeof entry.header?.size === 'number') {
if (entry.header.size > MAX_ENTRY_SIZE) {
return { success: false, error: `Eintrag "${entryName}" überschreitet das Größenlimit von 500 MB` };
}
totalUncompressed += entry.header.size;
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
return { success: false, error: `Backup-ZIP überschreitet das entpackte Gesamtlimit von 5 GB (Zip-Bomb-Schutz)` };
}
}
// Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben
+39 -9
View File
@@ -5,8 +5,19 @@ import * as consentService from './consent.service.js';
import * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit';
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
// hätte Jahre später noch fremde Einwilligungen erteilen können
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
// einen neuen Hash + neue Frist).
const CONSENT_HASH_TTL_DAYS = 30;
/**
* Kunden-Lookup per consentHash
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
* oder abgelaufen ist aus Sicht des Aufrufers identisch, damit der
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
*/
export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
customerNumber: true,
salutation: true,
email: true,
consentHashExpiresAt: true,
},
});
if (!customer) return null;
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
return null;
}
const consents = await consentService.getCustomerConsents(customer.id);
return { customer, consents };
// consentHashExpiresAt nicht an den Client durchreichen
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
void _expires;
return { customer: customerWithoutExpiry, consents };
}
/**
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
*/
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true },
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Ungültiger Link');
}
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
}
const results = [];
for (const type of Object.values(ConsentType)) {
@@ -56,26 +79,33 @@ export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
}
/**
* consentHash generieren falls nicht vorhanden
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist sonst wird ein
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
*/
export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { consentHash: true },
select: { consentHash: true, consentHashExpiresAt: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
if (customer.consentHash) {
return customer.consentHash;
const stillValid = customer.consentHash
&& customer.consentHashExpiresAt
&& customer.consentHashExpiresAt.getTime() > Date.now();
if (stillValid) {
return customer.consentHash!;
}
const hash = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
await prisma.customer.update({
where: { id: customerId },
data: { consentHash: hash },
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
});
return hash;
+4
View File
@@ -15,6 +15,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash',
// Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links kein Leak
// an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
// den Workflow gibt.
'consentHashExpiresAt',
// Session-/OTP-State Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne