diff --git a/backend/prisma/migrations/20260601300000_consent_hash_ttl/migration.sql b/backend/prisma/migrations/20260601300000_consent_hash_ttl/migration.sql new file mode 100644 index 00000000..ea099307 --- /dev/null +++ b/backend/prisma/migrations/20260601300000_consent_hash_ttl/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e42b7b9c..7feed462 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -157,7 +157,8 @@ model Customer { commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug commercialRegisterNumber String? // Handelsregisternummer (Text) privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden) - consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/ + consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/ + 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 // ===== Portal-Zugangsdaten ===== diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts index 6cde7d91..a6d2d619 100644 --- a/backend/src/services/backup.service.ts +++ b/backend/src/services/backup.service.ts @@ -1040,20 +1040,41 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise 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 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 diff --git a/backend/src/services/consent-public.service.ts b/backend/src/services/consent-public.service.ts index 2acea292..72896b98 100644 --- a/backend/src/services/consent-public.service.ts +++ b/backend/src/services/consent-public.service.ts @@ -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 { 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; diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index ece26373..cc9c9b94 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -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