Files
opencrm/backend/src/index.ts
T
duffyduck 3fb1925a98 security: HTTPS-only-Header per HTTPS_ENABLED-Flag steuern
`upgrade-insecure-requests` (CSP) + HSTS sperrten den Browser bei direktem
http://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR auf den Vite-Assets,
weil Browser sie via https laden wollte).

Beide Header sind jetzt default OFF und werden nur gesetzt, wenn
HTTPS_ENABLED=true – also sobald ein TLS-Reverse-Proxy (Caddy/Traefik/Nginx)
vor OpenCRM steht. Lokale + non-TLS-Deployments laufen damit ohne Stolperfalle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:00:01 +02:00

318 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express';
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;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// ==================== 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/<object>/<embed>-Embeds
// - base-uri 'self' → keine <base>-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'"],
'frame-ancestors': ["'none'"],
'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 nur wenn echt TLS vorhanden sonst sperrt sich der Browser
// dauerhaft aus, wenn die App direkt via http://ip:port erreichbar ist.
strictTransportSecurity: httpsEnabled
? { maxAge: 31536000, includeSubDomains: true }
: 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' }));
// 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/<filename> 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);
});
// Ö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');
// Serve static files
app.use(express.static(publicPath));
// 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();
}
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();
});