Compare commits
3 Commits
da1934aa2d
...
9482424ade
| Author | SHA1 | Date | |
|---|---|---|---|
| 9482424ade | |||
| a023e96012 | |||
| 72de2f00f3 |
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
|
|||||||
fi
|
fi
|
||||||
echo "[entrypoint] DB-Schema aktuell"
|
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.
|
# 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).
|
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||||
USER_COUNT=$(node -e "
|
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
|
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 =====
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
|||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
|
import { sanitizeNotes, stripHtml } from '../utils/sanitize.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||||
import { generateEmailPdf } from '../services/pdfService.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 { DocumentType } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -1876,18 +1877,23 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
|
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);
|
fs.writeFileSync(filePath, pdfBuffer);
|
||||||
|
return prisma.contractDocument.create({
|
||||||
const doc = await prisma.contractDocument.create({
|
|
||||||
data: {
|
data: {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
documentType,
|
documentType: cleanType,
|
||||||
documentPath: relativePath,
|
documentPath: relativePath,
|
||||||
originalName: `${email.subject || 'email'}.pdf`,
|
originalName: `${email.subject || 'email'}.pdf`,
|
||||||
notes: notes || null,
|
notes: sanitizeNotes(notes),
|
||||||
uploadedBy: (req as any).user?.email || 'email-import',
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||||
@@ -2173,18 +2179,21 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
|
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);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
return prisma.contractDocument.create({
|
||||||
const doc = await prisma.contractDocument.create({
|
|
||||||
data: {
|
data: {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
documentType,
|
documentType: cleanType,
|
||||||
documentPath: relativePath,
|
documentPath: relativePath,
|
||||||
originalName: filename,
|
originalName: filename,
|
||||||
notes: notes || null,
|
notes: sanitizeNotes(notes),
|
||||||
uploadedBy: (req as any).user?.email || 'email-import',
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import * as authorizationService from '../services/authorization.service.js';
|
|||||||
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.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 { 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
|
* 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> {
|
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
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
|
// Vorherigen Stand laden für Audit-Vergleich
|
||||||
const before = await prisma.contract.findUnique({
|
const before = await prisma.contract.findUnique({
|
||||||
where: { id: contractId },
|
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> {
|
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
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 } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await contractService.deleteContract(contractId);
|
await contractService.deleteContract(contractId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -727,16 +734,20 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
||||||
const doc = await prisma.contractDocument.create({
|
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: {
|
data: {
|
||||||
contractId,
|
contractId,
|
||||||
documentType,
|
documentType: cleanType,
|
||||||
documentPath,
|
documentPath,
|
||||||
originalName: req.file.originalname,
|
originalName: req.file!.originalname,
|
||||||
notes: notes || null,
|
notes: sanitizeNotes(notes),
|
||||||
uploadedBy: req.user?.email,
|
uploadedBy: req.user?.email,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await logChange({
|
await logChange({
|
||||||
|
|||||||
@@ -65,7 +65,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
||||||
try {
|
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 { requestSource } = req.body;
|
||||||
|
|
||||||
const request = await gdprService.createDeletionRequest({
|
const request = await gdprService.createDeletionRequest({
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Neue Rechnung hinzufügen
|
* 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> {
|
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||||
|
|
||||||
const invoice = await invoiceService.addInvoice(ecdId, {
|
const invoice = await invoiceService.addInvoice(ecdId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
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, {
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
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);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
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, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
invoiceType,
|
invoiceType,
|
||||||
|
|||||||
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
|||||||
return (downloadFile 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
|
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
||||||
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
||||||
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
// 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 { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -164,9 +188,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bankCardId = parseInt(req.params.id);
|
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}`;
|
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
|
||||||
|
|
||||||
// Bankkarte in der DB aktualisieren
|
|
||||||
await prisma.bankCard.update({
|
await prisma.bankCard.update({
|
||||||
where: { id: bankCardId },
|
where: { id: bankCardId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -183,6 +218,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,9 +240,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const documentId = parseInt(req.params.id);
|
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}`;
|
const relativePath = `/uploads/documents/${req.file.filename}`;
|
||||||
|
|
||||||
// Ausweis in der DB aktualisieren
|
|
||||||
await prisma.identityDocument.update({
|
await prisma.identityDocument.update({
|
||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -223,6 +270,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
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' });
|
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessBankCard(req, res, bankCardId))) return;
|
||||||
|
|
||||||
if (!bankCard.documentPath) {
|
if (!bankCard.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
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' });
|
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
|
||||||
|
|
||||||
if (!document.documentPath) {
|
if (!document.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
@@ -334,6 +386,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
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}`;
|
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -383,6 +440,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
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}`;
|
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -424,6 +486,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
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 } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -464,6 +528,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
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 } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -514,6 +580,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
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}`;
|
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -574,6 +648,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
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 } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -846,14 +922,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = parseInt(req.params.id);
|
const invoiceId = parseInt(req.params.id);
|
||||||
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
|
||||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
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) {
|
if (invoice.documentPath) {
|
||||||
const oldPath = path.join(process.cwd(), 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' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
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) {
|
if (!invoice.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
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);
|
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;
|
||||||
|
|||||||
@@ -84,6 +84,59 @@ export function startContractStatusScheduler(): void {
|
|||||||
|
|
||||||
export { runExpireCheck };
|
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
|
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
||||||
* Lieferbestätigung ist:
|
* Lieferbestätigung ist:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -166,6 +170,29 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
|||||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
* 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
|
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
|
||||||
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
||||||
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
||||||
|
|||||||
Reference in New Issue
Block a user