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