diff --git a/backend/src/index.ts b/backend/src/index.ts index d6466964..5436ea24 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -169,6 +169,11 @@ app.use( '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. @@ -182,11 +187,11 @@ app.use( '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, + // 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' }, }), ); @@ -235,6 +240,15 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => { 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(); +}); + // Öffentliche Routes (OHNE Authentifizierung) app.use('/api/public/consent', consentPublicRoutes); @@ -279,8 +293,29 @@ app.get('/api/health', (req, res) => { if (process.env.NODE_ENV === 'production') { const publicPath = path.join(process.cwd(), 'public'); - // Serve static files - app.use(express.static(publicPath)); + // 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) => { @@ -288,6 +323,9 @@ if (process.env.NODE_ENV === 'production') { 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')); }); } diff --git a/docs/todo.md b/docs/todo.md index f23d6149..a79beaa6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛡️ Pentest-Hardening-Runde 11: Header-Hygiene** + - **HSTS-Doppel-Header** (18× low im Audit): Helmet's + `Strict-Transport-Security` komplett deaktiviert. Der Nginx Proxy Manager + vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797. + - **Cache-Control** (≥10× info im Audit): + `/api/*` bekommt `no-store` (sensible JSON-Daten), + SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`, `/vite.svg`) bekommt + `no-store, must-revalidate` (sonst hängt Browser an alter index.html + fest nach Deploy), + `/assets/*` (Vite-Build mit Content-Hash im Filename) bekommt + `public, max-age=31536000, immutable`. + - **CSP No-Fallback-Direktiven** (2× medium): `worker-src`, `manifest-src`, + `media-src` explizit auf `'self'` – ZAP markiert sonst „Failure to + Define Directive with No Fallback". + - Bewusst NICHT angefasst: `style-src 'unsafe-inline'` (Tailwind/React- + inline-styles, kompletter Refactor unverhältnismäßig). + - Live verifiziert: Headers für `/`, `/api/*`, `/assets/*.js` und SPA- + Fallback-Pfade alle wie erwartet. + - [x] **🐛 PDF-Vorschau im PDF-Template-Editor lädt nicht** - CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings der eigenen Resourcen, auch same-origin – Browser zeigte je nach