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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user