security: Header-Hygiene-Runde 11 (Pentest-Cleanup)
Behebt die drei behebbaren Klassen aus dem ZAP-/Nikto-Audit vom 2026-05-16:
1. HSTS-Doppel-Header (18 Findings):
Helmet's strictTransportSecurity komplett deaktiviert. Der Nginx Proxy
Manager vor der CRM-VM setzt HSTS bereits (Force SSL + HSTS Enabled +
HSTS Sub-domains via UI). Doppelter Header verletzt RFC 6797.
2. Cache-Control (~10 Findings):
- /api/* → 'no-store' (sensible JSON-Daten)
- SPA-HTML (/, /robots.txt, /sitemap.xml, /vite.svg) → 'no-store,
must-revalidate' (sonst hängt Browser nach Deploy an alter index.html
mit alten Asset-Hashes fest)
- /assets/*.{js,css} (Vite-Build mit Content-Hash) → 'public,
max-age=31536000, immutable'
3. CSP No-Fallback-Direktiven (2 Findings):
worker-src, manifest-src, media-src jetzt explizit auf 'self'. ZAP
meckert sonst "Failure to Define Directive with No Fallback".
Bewusst NICHT gefixt: style-src 'unsafe-inline' (11 Findings). Tailwind +
React (style={{…}}) erzeugen viele inline-styles; nonce-/hash-basierte CSP
wäre ein größerer Build- und Code-Refactor mit eher kosmetischem Gewinn,
da der primäre XSS-Schutz weiterhin via script-src 'self' und Input-
Sanitization greift.
Live verifiziert (Headers via curl gegen HTTPS_ENABLED=true Container):
- / → 'no-store, must-revalidate', kein HSTS
- /assets/index-*.js → 'public, max-age=31536000, immutable', kein HSTS
- /api/health → 'no-store', kein HSTS
- SPA-Fallback (/sitemap.xml, /robots.txt) → 'no-store, must-revalidate'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-7
@@ -169,6 +169,11 @@ app.use(
|
|||||||
'img-src': ["'self'", 'data:', 'blob:'],
|
'img-src': ["'self'", 'data:', 'blob:'],
|
||||||
'font-src': ["'self'", 'data:'],
|
'font-src': ["'self'", 'data:'],
|
||||||
'connect-src': ["'self'"],
|
'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
|
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
|
||||||
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
|
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
|
||||||
// 'none' würde sogar same-origin blocken und damit die UI brechen.
|
// 'none' würde sogar same-origin blocken und damit die UI brechen.
|
||||||
@@ -182,11 +187,11 @@ app.use(
|
|||||||
'upgrade-insecure-requests': httpsEnabled ? [] : null,
|
'upgrade-insecure-requests': httpsEnabled ? [] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// HSTS nur wenn echt TLS vorhanden – sonst sperrt sich der Browser
|
// HSTS NIE in Helmet senden – der vorgelagerte TLS-Reverse-Proxy
|
||||||
// dauerhaft aus, wenn die App direkt via http://ip:port erreichbar ist.
|
// (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt
|
||||||
strictTransportSecurity: httpsEnabled
|
// RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt.
|
||||||
? { maxAge: 31536000, includeSubDomains: true }
|
// HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant.
|
||||||
: false,
|
strictTransportSecurity: false,
|
||||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
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);
|
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)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
app.use('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
||||||
@@ -279,8 +293,29 @@ app.get('/api/health', (req, res) => {
|
|||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(process.cwd(), 'public');
|
const publicPath = path.join(process.cwd(), 'public');
|
||||||
|
|
||||||
// Serve static files
|
// Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
|
||||||
app.use(express.static(publicPath));
|
// 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
|
// SPA fallback: serve index.html for all non-API routes
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
@@ -288,6 +323,9 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
if (req.path.startsWith('/api')) {
|
if (req.path.startsWith('/api')) {
|
||||||
return next();
|
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'));
|
res.sendFile(path.join(publicPath, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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**
|
- [x] **🐛 PDF-Vorschau im PDF-Template-Editor lädt nicht**
|
||||||
- CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
|
- CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
|
||||||
der eigenen Resourcen, auch same-origin – Browser zeigte je nach
|
der eigenen Resourcen, auch same-origin – Browser zeigte je nach
|
||||||
|
|||||||
Reference in New Issue
Block a user