3fb1925a98
`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>
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
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();
|
||
});
|