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
@@ -0,0 +1,16 @@
-- Consent-Hash bekommt eine Ablauffrist (Pentest 57.7 MEDIUM).
-- Public-Consent-Links liefen vorher nie ab DSGVO-Risiko, weil ein
-- weitergegebener Link Jahre später noch Einwilligungen erteilen konnte.
-- 30 Tage Default; nach Ablauf liefert getCustomerByConsentHash null.
-- Bestandsdaten ohne Ablaufzeit bekommen `NOW() + 30 Tage` als Frist,
-- damit existierende, frisch versendete Links nicht sofort tot sind.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher.
ALTER TABLE `Customer`
ADD COLUMN IF NOT EXISTS `consentHashExpiresAt` DATETIME(3) NULL;
UPDATE `Customer`
SET `consentHashExpiresAt` = DATE_ADD(NOW(), INTERVAL 30 DAY)
WHERE `consentHash` IS NOT NULL
AND `consentHashExpiresAt` IS NULL;
+2 -1
View File
@@ -157,7 +157,8 @@ model Customer {
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
commercialRegisterNumber String? // Handelsregisternummer (Text) commercialRegisterNumber String? // Handelsregisternummer (Text)
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden) privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/<hash> consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
notes String? @db.Text notes String? @db.Text
// ===== Portal-Zugangsdaten ===== // ===== Portal-Zugangsdaten =====
+36 -5
View File
@@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir); const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff). // 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); const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true }); 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) { for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName; 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}` }; return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
} }
const targetPath = path.resolve(absBackupDir, entryName); const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen // Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) { // 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 { return {
success: false, success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`, 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) { if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true }); fs.mkdirSync(targetPath, { recursive: true });
} else { } 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 // Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben // 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 * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit'; 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) { export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({ const customer = await prisma.customer.findUnique({
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
customerNumber: true, customerNumber: true,
salutation: true, salutation: true,
email: true, email: true,
consentHashExpiresAt: true,
}, },
}); });
if (!customer) return null; if (!customer) return null;
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
return null;
}
const consents = await consentService.getCustomerConsents(customer.id); 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) { export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({ const customer = await prisma.customer.findUnique({
where: { consentHash: hash }, where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
}); });
if (!customer) { if (!customer) {
throw new Error('Ungültiger Link'); 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 = []; const results = [];
for (const type of Object.values(ConsentType)) { 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> { export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({ const customer = await prisma.customer.findUnique({
where: { id: customerId }, where: { id: customerId },
select: { consentHash: true }, select: { consentHash: true, consentHashExpiresAt: true },
}); });
if (!customer) { if (!customer) {
throw new Error('Kunde nicht gefunden'); throw new Error('Kunde nicht gefunden');
} }
if (customer.consentHash) { const stillValid = customer.consentHash
return customer.consentHash; && customer.consentHashExpiresAt
&& customer.consentHashExpiresAt.getTime() > Date.now();
if (stillValid) {
return customer.consentHash!;
} }
const hash = crypto.randomUUID(); const hash = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
await prisma.customer.update({ await prisma.customer.update({
where: { id: customerId }, where: { id: customerId },
data: { consentHash: hash }, data: { consentHash: hash, consentHashExpiresAt: expiresAt },
}); });
return hash; return hash;
+4
View File
@@ -15,6 +15,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener // braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16). // Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash', '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 // 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 // einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne // wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne