Compare commits
3 Commits
da1934aa2d
...
9482424ade
| Author | SHA1 | Date | |
|---|---|---|---|
| 9482424ade | |||
| a023e96012 | |||
| 72de2f00f3 |
@@ -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 ~5–10 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user