import express from 'express'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import helmet from 'helmet'; import path from 'path'; import dotenv from 'dotenv'; import dotenvExpand from 'dotenv-expand'; // .env-Dateien laden – Root-.env hat Priorität (zentrale Konfiguration für // Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind // Variablen schon via env_file/environment gesetzt – dotenv überschreibt // existierende process.env-Werte nicht. // __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen // liegt Root /.env zwei Ebenen darüber. // // dotenvExpand löst ${VAR}-Substitution auf, sodass z.B. // DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} // dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen). dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') })); dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') })); dotenvExpand.expand(dotenv.config()); // Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution // nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen. if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) { const u = encodeURIComponent(process.env.DB_USER); const p = encodeURIComponent(process.env.DB_PASSWORD); const h = process.env.DB_HOST || 'localhost'; const port = process.env.DB_PORT || '3306'; process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`; } import authRoutes from './routes/auth.routes.js'; import customerRoutes from './routes/customer.routes.js'; import addressRoutes from './routes/address.routes.js'; import bankcardRoutes from './routes/bankcard.routes.js'; import documentRoutes from './routes/document.routes.js'; import meterRoutes from './routes/meter.routes.js'; import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js'; import contractRoutes from './routes/contract.routes.js'; import platformRoutes from './routes/platform.routes.js'; import cancellationPeriodRoutes from './routes/cancellation-period.routes.js'; import contractDurationRoutes from './routes/contract-duration.routes.js'; import providerRoutes from './routes/provider.routes.js'; import tariffRoutes from './routes/tariff.routes.js'; import userRoutes from './routes/user.routes.js'; import uploadRoutes from './routes/upload.routes.js'; import developerRoutes from './routes/developer.routes.js'; import contractCategoryRoutes from './routes/contractCategory.routes.js'; import contractTaskRoutes from './routes/contractTask.routes.js'; import appSettingRoutes from './routes/appSetting.routes.js'; import emailProviderRoutes from './routes/emailProvider.routes.js'; import cachedEmailRoutes from './routes/cachedEmail.routes.js'; import invoiceRoutes from './routes/invoice.routes.js'; import contractHistoryRoutes from './routes/contractHistory.routes.js'; import auditLogRoutes from './routes/auditLog.routes.js'; import gdprRoutes from './routes/gdpr.routes.js'; import consentPublicRoutes from './routes/consent-public.routes.js'; import emailLogRoutes from './routes/emailLog.routes.js'; import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; import birthdayRoutes from './routes/birthday.routes.js'; import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; import { downloadFile } from './controllers/fileDownload.controller.js'; import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js'; import { startSecurityMonitorScheduler } from './services/securityAlert.service.js'; import monitoringRoutes from './routes/monitoring.routes.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; import { authenticate } from './middleware/auth.js'; // ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ==================== if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)'); console.error(' Generiere mit: openssl rand -hex 64'); process.exit(1); } if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) { console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)'); console.error(' Generiere mit: openssl rand -hex 32'); process.exit(1); } const app = express(); const PORT = process.env.PORT || 3001; // Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`. // // Zwei Szenarien: // 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf // EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut // genau einem Hop → req.ip = echter Client (nicht der Proxy). // Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar, // sonst könnte ein Direkt-Connect X-Forwarded-For faken und den // Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das // durch das Docker-Network + nicht-veröffentlichten Backend-Port // gewährleistet. // 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port- // Zugriff): `loopback` reicht – kein vertrauenswürdiger Hop davor. // // Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup // IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und // IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client. const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback'; app.set('trust proxy', trustProxyValue); // ==================== SECURITY MIDDLEWARE ==================== // HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...) // // CSP ist konservativ aber SPA-tauglich: // - script-src 'self' → keine externen Skripte, keine inline-Scripts // (Vite baut Module-Skripte zu separaten Files, // die sind 'self') // - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das // (sicheres Trade-off; XSS via CSS ist // marginal vs Lock-Out gegen die UI) // - img-src self/data/blob → base64-Avatare + blob-URLs für PDFs/Downloads // - font-src self/data → eingebettete Fonts // - connect-src 'self' → API + WebSocket nur zur eigenen Origin // - frame-ancestors 'none' → Clickjacking-Schutz (ersetzt X-Frame-Options) // - object-src 'none' → keine Flash//-Embeds // - base-uri 'self' → keine -Hijacking-Tricks // - form-action 'self' → POST-Targets nur auf eigene Origin // Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen. // Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc. // bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten, // fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet. app.use((_req, res, next) => { res.setHeader( 'Permissions-Policy', [ 'accelerometer=()', 'ambient-light-sensor=()', 'autoplay=()', 'battery=()', 'camera=()', 'clipboard-read=()', 'clipboard-write=(self)', 'cross-origin-isolated=()', 'display-capture=()', 'encrypted-media=()', 'fullscreen=(self)', 'geolocation=()', 'gyroscope=()', 'hid=()', 'idle-detection=()', 'magnetometer=()', 'microphone=()', 'midi=()', 'payment=()', 'picture-in-picture=()', 'publickey-credentials-get=()', 'screen-wake-lock=()', 'sync-xhr=()', 'usb=()', 'web-share=()', 'xr-spatial-tracking=()', ].join(', '), ); next(); }); // HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn // wirklich TLS davor läuft – sonst sperrt sich die App auf direkt-via-IP- // Deployments (Browser versucht /assets/* via https zu laden → SSL-Error). // Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy // (Caddy/Traefik/Nginx) vor OpenCRM steht. const httpsEnabled = process.env.HTTPS_ENABLED === 'true'; app.use( helmet({ contentSecurityPolicy: { useDefaults: true, directives: { 'default-src': ["'self'"], 'script-src': ["'self'"], 'style-src': ["'self'", "'unsafe-inline'"], 'img-src': ["'self'", 'data:', 'blob:'], 'font-src': ["'self'", 'data:'], 'connect-src': ["'self'"], // Explizit gesetzt obwohl Fallback auf default-src/script-src greift – // ZAP markiert sonst "No-Fallback-Direktiven" als CSP-Lücke. 'worker-src': ["'self'"], 'manifest-src': ["'self'"], 'media-src': ["'self'"], // 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die // annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration). // 'none' würde sogar same-origin blocken und damit die UI brechen. // Externe Sites bleiben weiterhin gesperrt. 'frame-ancestors': ["'self'"], 'object-src': ["'none'"], 'base-uri': ["'self'"], 'form-action': ["'self'"], // useDefaults bringt 'upgrade-insecure-requests' selbst mit – explizit // auf null setzen entfernt es aus dem Header (helmet-API). 'upgrade-insecure-requests': httpsEnabled ? [] : null, }, }, // HSTS NIE in Helmet senden – der vorgelagerte TLS-Reverse-Proxy // (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt // RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt. // HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant. strictTransportSecurity: false, crossOriginResourcePolicy: { policy: 'same-site' }, }), ); // CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben. const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) : process.env.NODE_ENV === 'production' ? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert) : true; // Dev: alles erlauben app.use( cors({ origin: corsOrigins, credentials: true, }), ); // JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json()) app.use(express.json({ limit: '5mb' })); // Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht // (POST /api/auth/refresh liest ihn aus req.cookies). app.use(cookieParser()); // Audit-Logging Middleware (DSGVO-konform) app.use(auditContextMiddleware); app.use(auditMiddleware); // Datei-Download mit Per-File-Ownership-Check (ersetzt das alte // `/api/uploads/*` express.static). // Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/... // Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.) // und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde // nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt. // // Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden // Request über denselben Owner-Check (kein freier static-Handler mehr). // Authentifizierter Datei-Download mit Per-File-Ownership-Check. // Akzeptiert Pfade wie /uploads/bank-cards/ – egal ob als // Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler, // der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf. app.get('/api/files/download', authenticate as any, downloadFile as any); // Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher // für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler. app.get('/api/uploads/*', authenticate as any, (req, res, next) => { // Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen req.query.path = req.originalUrl.replace(/^\/api/, '').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 // (siehe express.static mit immutable weiter unten). app.use('/api', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }); // Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster // in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen. // So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe // Prisma-Internals wie "Invalid `prisma.user.update()` invocation: // Record to update not found" (Pentest Runde 11 M3). Der Original-Text // landet weiterhin im Server-Log. const ORM_LEAK_PATTERNS: RegExp[] = [ /Invalid `prisma\./i, /PrismaClient/i, /^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame /at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col // JS-Runtime-Fehler – Pentest Runde 12 (2026-05-18): "Cannot read // properties of undefined (reading 'substring')" leakte aus POST // /contracts. Solche Texte verraten Implementierungs-Details. /^TypeError\b/i, /^ReferenceError\b/i, /^SyntaxError\b/i, /^RangeError\b/i, /Cannot read propert(y|ies) of (undefined|null)/i, /is not a function/i, /is not defined$/i, ]; function sanitizeErrorString(s: string): string { if (!s) return s; for (const re of ORM_LEAK_PATTERNS) { if (re.test(s)) { console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300)); return 'Operation fehlgeschlagen'; } } return s; } app.use('/api', (_req, res, next) => { const originalJson = res.json.bind(res); res.json = (body: any) => { if (body && typeof body === 'object') { if (typeof body.error === 'string') { body.error = sanitizeErrorString(body.error); } if (typeof body.details === 'string') { body.details = sanitizeErrorString(body.details); } } return originalJson(body); }; next(); }); // Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was // dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert // wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input- // Validierung. Pentest Runde 7 (2026-05-17), LOW. // // `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb // machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` – // reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie // `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings // `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt // nicht geblockt. const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/; app.use('/api', (req, res, next) => { for (const seg of req.path.split('/')) { if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) { res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' }); return; } } next(); }); // Öffentliche Routes (OHNE Authentifizierung) app.use('/api/public/consent', consentPublicRoutes); // Routes app.use('/api/auth', authRoutes); app.use('/api/customers', customerRoutes); app.use('/api/addresses', addressRoutes); app.use('/api/bank-cards', bankcardRoutes); app.use('/api/documents', documentRoutes); app.use('/api/meters', meterRoutes); app.use('/api/stressfrei-emails', stressfreiEmailRoutes); app.use('/api/contracts', contractRoutes); app.use('/api/platforms', platformRoutes); app.use('/api/cancellation-periods', cancellationPeriodRoutes); app.use('/api/contract-durations', contractDurationRoutes); app.use('/api/providers', providerRoutes); app.use('/api/tariffs', tariffRoutes); app.use('/api/users', userRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/developer', developerRoutes); app.use('/api/contract-categories', contractCategoryRoutes); app.use('/api', contractTaskRoutes); app.use('/api/settings', appSettingRoutes); app.use('/api/email-providers', emailProviderRoutes); app.use('/api', cachedEmailRoutes); app.use('/api/energy-details', invoiceRoutes); app.use('/api', contractHistoryRoutes); app.use('/api/audit-logs', auditLogRoutes); app.use('/api/gdpr', gdprRoutes); app.use('/api/email-logs', emailLogRoutes); app.use('/api/pdf-templates', pdfTemplateRoutes); app.use('/api/birthdays', birthdayRoutes); app.use('/api/factory-defaults', factoryDefaultsRoutes); app.use('/api/monitoring', monitoringRoutes); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Production: Serve frontend static files if (process.env.NODE_ENV === 'production') { const publicPath = path.join(process.cwd(), 'public'); // Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash // im Dateinamen – das Image ist also versioniert. Daher kann der Browser // sie für ein Jahr aggressiv cachen und muss nicht revalidieren. app.use( '/assets', express.static(path.join(publicPath, 'assets'), { maxAge: '1y', immutable: true, }), ); // Rest des Frontends (index.html selbst, vite.svg, robots.txt, sitemap.xml). // express.static findet index.html bei GET /, deshalb MUSS hier das gleiche // no-store-Verhalten greifen wie im SPA-Fallback weiter unten – sonst // serviert der erste Static-Handler / mit dem express-Default `max-age=0`, // bevor der Fallback überhaupt greift, und der Browser cached die alte SPA. app.use( express.static(publicPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-store, must-revalidate'); }, }), ); // SPA fallback: serve index.html for all non-API routes app.get('*', (req, res, next) => { // Skip API routes if (req.path.startsWith('/api')) { return next(); } // SPA-Wurzel darf NIE gecached werden – sonst sieht der Browser nach einem // Deploy weiterhin die alte index.html mit alten Asset-Hashes. res.setHeader('Cache-Control', 'no-store, must-revalidate'); res.sendFile(path.join(publicPath, 'index.html')); }); } // Error handling // body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem // `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500 // kaschiert und landen als "Interner Serverfehler" beim User. app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error(err.stack); const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500; let message = 'Interner Serverfehler'; if (status === 413) message = 'Anfrage zu groß'; else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) { message = 'Ungültiges JSON'; } res.status(status).json({ success: false, error: message }); }); // Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler // Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar. const LISTEN_ADDR = process.env.LISTEN_ADDR || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0'); app.listen(PORT as number, LISTEN_ADDR, () => { console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`); // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten startBirthdayScheduler(); startContractStatusScheduler(); startSecurityMonitorScheduler(); });