Compare commits

...

3 Commits

Author SHA1 Message Date
duffyduck 9482424ade 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>
2026-06-01 21:13:06 +02:00
duffyduck a023e96012 Pentest 56.1/56.2/56.3/56.4/56.5: Ownership-Checks + InvoiceType-Validierung
56.1 HIGH (IDOR auf Upload-Endpoints):
- /upload/bank-cards/:id (POST/DELETE): canAccessBankCard +
  Existenz-Check, multer-Datei wird bei Reject sauber aufgeräumt.
- /upload/documents/:id (POST/DELETE): canAccessIdentityDocument
  + Existenz-Check + Cleanup.
- /upload/customers/:id/{business-registration,commercial-register,
  privacy-policy} (POST/DELETE): canAccessCustomer + Cleanup.
- /upload/invoices/:id (POST/DELETE): canAccessContract über
  Invoice→Contract-Resolve + Cleanup.

56.2 HIGH (IDOR + Consent-Eskalation bei privacy-policy):
- Vor dem upsert auf alle 4 CustomerConsent-Einträge (=GRANTED)
  läuft jetzt canAccessCustomer. Portal-Vertreter ohne Vollmacht
  oder Mitarbeiter mit anderer Customer-Beschränkung kommen
  damit nicht mehr durch.

56.3 LATENT (updateContract / deleteContract):
- Defense-in-Depth: canAccessContract jetzt explizit im Controller,
  nicht nur über die Route-Permission.

56.4 MEDIUM (invoiceType ungeprüft in addInvoiceByContract):
- Neuer assertValidInvoiceType-Helper mit Whitelist
  ['INTERIM','FINAL','NOT_AVAILABLE'] in addInvoice,
  updateInvoice und addInvoiceByContract. updateInvoice nur
  bei explizit gesetztem Wert; addInvoiceByContract zusätzlich
  die fehlende Required-Field-Validierung ergänzt.

56.5 LOW (GDPR-Löschanfragen ohne Ownership-Check):
- POST /api/gdpr/deletions liest customerId jetzt aus dem Body
  (Route hat kein :id-Segment), validiert auf positive Zahl und
  ruft canAccessCustomer auf, bevor die Löschanfrage erstellt wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:01:06 +02:00
duffyduck 72de2f00f3 Pentest 55.2 + 55.3 HIGH + 55.4 + 53.3: Notes/Document-Auth/Race/Generate
55.3 HIGH (Contract-Documents ohne Auth abrufbar):
- /uploads/contract-documents/*.pdf war HTTP 200 ohne Token, weil
  nginx die Datei direkt ausliefert und Backend nur /api/uploads/*
  schützte.
- Defense-in-Depth: app.get('/uploads/*') jetzt ebenfalls mit
  authenticate + downloadFile (Ownership-Check) abgesichert.
  Falls nginx fehlkonfiguriert sein sollte, fängt das Backend.

55.2 MEDIUM (notes ungestrippt + unlimitiert):
- Neuer sanitizeNotes-Helper: stripHtml + CRLF→LF + Control-Chars
  raus + Cap 2000 Zeichen. Eingesetzt für ContractDocument.notes
  in allen 3 Schreibpfaden (contract.controller, saveAttachment-
  AsContractDocument, saveEmailAsContractDocument).
- documentType zusätzlich stripHtml.

55.4 LOW (Race: 5x Lieferbestätigung → 5 Dokumente):
- Neuer In-Memory-Lock per (contractId, documentType) in
  contractStatusScheduler.service. withContractDocumentLock führt
  Recent-Duplicate-Check (10s-Window) + Write atomar aus.
- In cachedEmail-Pfaden: fs.writeFileSync ist jetzt INNERHALB des
  Locks → kein verwaister Datei-Müll bei Race-Reject.

53.3 (Prisma-Client veraltet bei ungebauten Images):
- docker-entrypoint.sh: `prisma generate` am Container-Start
  hinzugefügt. Kostet ~5–10 s, regeneriert den Client gegen das
  aktuelle Schema falls jemand ein Stale-Image hochgezogen hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:45:39 +02:00
13 changed files with 380 additions and 57 deletions
+8
View File
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
fi
echo "[entrypoint] DB-Schema aktuell"
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
# generate` am Start regeneriert den Client gegen das aktuelle Schema
# und kostet ~510 s tradeoff für Robustheit.
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen nicht kritisch, Client bleibt aus Image"
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e "
@@ -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
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/<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
// ===== Portal-Zugangsdaten =====
@@ -8,11 +8,12 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml } from '../utils/sanitize.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
@@ -1876,17 +1877,22 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, pdfBuffer);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
// Datei-Müll bei Race-Reject.
const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
fs.writeFileSync(filePath, pdfBuffer);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType: cleanType,
documentPath: relativePath,
originalName: `${email.subject || 'email'}.pdf`,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
@@ -2173,17 +2179,20 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
const doc = await withContractDocumentLock(contract.id, cleanType, async () => {
fs.writeFileSync(filePath, attachment.content);
return prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType: cleanType,
documentPath: relativePath,
originalName: filename,
notes: sanitizeNotes(notes),
uploadedBy: (req as any).user?.email || 'email-import',
},
});
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
+23 -12
View File
@@ -7,9 +7,9 @@ import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -181,6 +181,11 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth
// canAccessContract explizit aufrufen, statt sich nur auf die
// Route-Permission zu verlassen. Portal-User mit kompromittierter
// Token-Permission würden sonst beliebige Verträge editieren können.
if (!(await canAccessContract(req, res, contractId))) return;
// Vorherigen Stand laden für Audit-Vergleich
const before = await prisma.contract.findUnique({
where: { id: contractId },
@@ -264,6 +269,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
export async function deleteContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent): Defense-in-Depth Ownership-Check vor Delete.
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await contractService.deleteContract(contractId);
await logChange({
@@ -727,16 +734,20 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
}
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
const doc = await prisma.contractDocument.create({
data: {
contractId,
documentType,
documentPath,
originalName: req.file.originalname,
notes: notes || null,
uploadedBy: req.user?.email,
},
});
const cleanType = stripHtml(documentType) as string;
// Pentest 55.4: Race-Schutz Lock + Recent-Duplicate-Check.
const doc = await withContractDocumentLock(contractId, cleanType, () =>
prisma.contractDocument.create({
data: {
contractId,
documentType: cleanType,
documentPath,
originalName: req.file!.originalname,
notes: sanitizeNotes(notes),
uploadedBy: req.user?.email,
},
}),
);
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({
+14 -1
View File
@@ -65,7 +65,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
*/
export async function createDeletionRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
// aus dem Body kommen (Route hat kein :id-Segment) und der Caller
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
const bodyCustomerId = req.body?.customerId;
const customerId = typeof bodyCustomerId === 'number'
? bodyCustomerId
: parseInt(bodyCustomerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const { requestSource } = req.body;
const request = await gdprService.createDeletionRequest({
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Neue Rechnung hinzufügen
*/
// Pentest 56.4 (MEDIUM, 2026-06-01): invoiceType wurde an manchen
// Endpunkten nicht gegen die Enum-Whitelist validiert; ein beliebiger
// String landete als invoiceType in der DB und konnte Frontend-
// Filter/Reports verwirren oder XSS in Audit-Labels einschleusen.
type ValidInvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
const VALID_INVOICE_TYPES: Set<ValidInvoiceType> = new Set(['INTERIM', 'FINAL', 'NOT_AVAILABLE']);
function assertValidInvoiceType(value: unknown, res: Response): value is ValidInvoiceType {
if (typeof value !== 'string' || !VALID_INVOICE_TYPES.has(value as ValidInvoiceType)) {
res.status(400).json({
success: false,
error: `Ungültiger Rechnungstyp. Erlaubt: ${[...VALID_INVOICE_TYPES].join(', ')}.`,
} as ApiResponse);
return false;
}
return true;
}
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoice(ecdId, {
invoiceDate: new Date(invoiceDate),
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
// 56.4: invoiceType ist beim Update optional nur prüfen wenn gesetzt.
if (invoiceType !== undefined && !assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
@@ -168,6 +188,14 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
if (!invoiceDate || !invoiceType) {
res.status(400).json({
success: false,
error: 'invoiceDate und invoiceType sind erforderlich',
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
invoiceDate: new Date(invoiceDate),
invoiceType,
+11
View File
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
return (downloadFile as any)(req, res, next);
});
// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
// ohne /api-Präfix freischalten damit der Backend-Owner-Check immer
// läuft, egal wie nginx konfiguriert ist.
app.get('/uploads/*', authenticate as any, (req, res, next) => {
req.query.path = req.originalUrl.split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
+91 -6
View File
@@ -6,7 +6,31 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
import {
canAccessContract,
canAccessCustomer,
canAccessBankCard,
canAccessIdentityDocument,
} from '../utils/accessControl.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
// für saubere 404-Antwort + Datei-Cleanup, wenn die Resource nicht
// existiert. Anschließend laufen die `canAccess*`-Checks (Portal-User
// werden dort auf ihre eigenen Kunden eingeschränkt; Staff bekommen
// volle Sicht konsistent mit der bestehenden Access-Control-Logik).
async function resolveInvoiceContractId(invoiceId: number): Promise<number | null> {
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
select: { contractId: true, energyContractDetails: { select: { contractId: true } } },
});
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
}
function cleanupFile(filePath?: string) {
if (!filePath) return;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
const router = Router();
@@ -164,9 +188,20 @@ router.post(
}
const bankCardId = parseInt(req.params.id);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
if (!card) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
if (!(await canAccessBankCard(req, res, bankCardId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
// Bankkarte in der DB aktualisieren
await prisma.bankCard.update({
where: { id: bankCardId },
data: { documentPath: relativePath },
@@ -183,6 +218,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -204,9 +240,20 @@ router.post(
}
const documentId = parseInt(req.params.id);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
if (!doc) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
if (!(await canAccessIdentityDocument(req, res, documentId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/documents/${req.file.filename}`;
// Ausweis in der DB aktualisieren
await prisma.identityDocument.update({
where: { id: documentId },
data: { documentPath: relativePath },
@@ -223,6 +270,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -246,6 +294,8 @@ router.delete(
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessBankCard(req, res, bankCardId))) return;
if (!bankCard.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
@@ -290,6 +340,8 @@ router.delete(
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
if (!document.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
@@ -334,6 +386,11 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -383,6 +440,11 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -424,6 +486,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -464,6 +528,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -514,6 +580,14 @@ router.post(
}
const customerId = parseInt(req.params.id);
// Pentest 56.2 (HIGH): Ownership-Check VOR Consent-Massen-Update.
// Ohne diese Prüfung konnte jeder Caller mit customers:update für
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
// (DSGVO-Eskalation).
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -574,6 +648,8 @@ router.delete(
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
@@ -846,14 +922,20 @@ router.post(
}
const invoiceId = parseInt(req.params.id);
const relativePath = `/uploads/invoices/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/invoices/${req.file.filename}`;
if (invoice.documentPath) {
const oldPath = path.join(process.cwd(), invoice.documentPath);
@@ -898,6 +980,9 @@ router.delete(
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
// Pentest 56.1: Ownership-Check vor Delete.
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) return;
if (!invoice.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
+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;
@@ -84,6 +84,59 @@ export function startContractStatusScheduler(): void {
export { runExpireCheck };
/**
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
* documentType) verhindert das in der Praxis (single-instance) und bietet
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
*
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
* legitimen Sekunden-später-Updates.
*/
const docCreateLocks = new Map<string, Promise<void>>();
export async function assertNoRecentDuplicateDocument(
contractId: number,
documentType: string,
): Promise<void> {
const recent = await prisma.contractDocument.findFirst({
where: {
contractId,
documentType,
createdAt: { gte: new Date(Date.now() - 10_000) },
},
select: { id: true },
});
if (recent) {
throw new Error('Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt bitte kurz warten und Seite neu laden.');
}
}
export async function withContractDocumentLock<T>(
contractId: number,
documentType: string,
fn: () => Promise<T>,
): Promise<T> {
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
const previous = docCreateLocks.get(key);
let release: () => void = () => {};
const slot = new Promise<void>((resolve) => { release = resolve; });
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
if (previous) await previous;
try {
await assertNoRecentDuplicateDocument(contractId, documentType);
return await fn();
} finally {
release();
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
docCreateLocks.delete(key);
}
}
}
/**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist:
+27
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
@@ -166,6 +170,29 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
* Audit-Log abrufbar) und sanitisiert das embedded customer.
*/
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
// - Tags + gefährliche Schemata via stripHtml
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') return null;
const stripped = stripHtml(raw) as string;
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
// herausfiltern. Null/Form-Feed/Tabs raus.
const normalized = stripped
.replace(/\r\n?/g, '\n')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
const trimmed = normalized.trim();
if (trimmed === '') return null;
return trimmed.slice(0, maxLength);
}
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen