diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index a5fbeae4..b0c1b29f 100755 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -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 " diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 6b2f681d..a1b81abb 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -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 diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index 730b58c5..c699d01e 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -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 @@ -727,16 +727,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({ diff --git a/backend/src/index.ts b/backend/src/index.ts index 2208ae88..97f2b906 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 diff --git a/backend/src/services/contractStatusScheduler.service.ts b/backend/src/services/contractStatusScheduler.service.ts index 762dab4e..b03e8b11 100644 --- a/backend/src/services/contractStatusScheduler.service.ts +++ b/backend/src/services/contractStatusScheduler.service.ts @@ -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>(); + +export async function assertNoRecentDuplicateDocument( + contractId: number, + documentType: string, +): Promise { + 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( + contractId: number, + documentType: string, + fn: () => Promise, +): Promise { + const key = `${contractId}|${documentType.trim().toLowerCase()}`; + const previous = docCreateLocks.get(key); + let release: () => void = () => {}; + const slot = new Promise((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: diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index b02dbabf..ece26373 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -166,6 +166,29 @@ export function sanitizeCustomers>(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