feat: Content-Security-Policy im Report-Only-Modus einfuehren
L4 aus dem TODO-Block Sicherheitsdurchgang. Bewusst vorsichtig: die Policy laeuft zunaechst nur als "Content-Security-Policy-Report-Only" und blockiert nichts, meldet aber ueber den neuen Endpunkt POST /api/csp-report jeden Verstoss (console.error/PM2). Whitelist aus dem tatsaechlichen Code abgeleitet, nicht geraten: - fonts.googleapis.com/fonts.gstatic.com: FontPicker.tsx/WeeklyView.tsx laden Nutzer-Schriftarten zur Laufzeit per <link> nach - 'unsafe-inline' fuer script-src/style-src: Next.js' App-Router-Hydration und der eine dangerouslySetInnerHTML-Block in WeeklyView.tsx (View-Transition-Styles) brauchen das ohne Nonce-Infrastruktur (eigener, groesserer Umbau) - Kein <iframe>, keine Drittanbieter-Bilder, keine clientseitigen fetch()-Aufrufe zu fremden Domains gefunden. FontAwesome laeuft als React-Komponente im eigenen Bundle. OAuth-Redirects sind Top-Level-Navigation, von CSP nicht betroffen. src/middleware.ts: /api/csp-report von der Login-Pflicht ausgenommen -- der Browser schickt Reports automatisch, auch von der nicht angemeldeten Login-Seite. Scharf schalten (Report-Only aus dem Header-Namen entfernen) erst, nachdem ein paar Tage Reports durchgesehen wurden -- die OAuth-Flows (Google/Microsoft/Apple/Notion) liessen sich hier nicht interaktiv durchklicken. Verifiziert: Typecheck, Lint, 85 Unit-Tests, Produktions-Build, Header und Report-Endpunkt manuell im gebauten Standalone-Server geprueft. v1.125.0 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
158051bb47
commit
a20cc6dc65
18
CHANGELOG.md
18
CHANGELOG.md
@ -2,10 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
## [1.124.0] - noch nicht deployed
|
## [1.125.0] - 2026-09-12
|
||||||
|
|
||||||
**Achtung: dieser Commit braucht `CREDENTIAL_ENCRYPTION_KEY` in der Produktions-`.env`,
|
### Security
|
||||||
bevor er deployed wird — siehe unten.**
|
- **Content-Security-Policy ergänzt, vorerst als `Content-Security-Policy-Report-Only`.**
|
||||||
|
Blockiert nichts, meldet aber jeden Verstoß an den neuen Endpunkt `POST /api/csp-report`
|
||||||
|
(loggt über `console.error`/PM2). Die Whitelist ist aus dem tatsächlichen Code abgeleitet:
|
||||||
|
`fonts.googleapis.com`/`fonts.gstatic.com` für zur Laufzeit nachgeladene Schriftarten
|
||||||
|
(`FontPicker.tsx`, `WeeklyView.tsx`), `'unsafe-inline'` für Next.js' App-Router-Hydration
|
||||||
|
und den einen `dangerouslySetInnerHTML`-Block. Kein `<iframe>`, keine Drittanbieter-Bilder,
|
||||||
|
keine clientseitigen Fetches zu fremden Domains gefunden. Scharf geschaltet wird erst,
|
||||||
|
nachdem ein paar Tage Reports durchgesehen wurden — insbesondere die OAuth-Login-Flows
|
||||||
|
ließen sich hier nicht interaktiv durchklicken.
|
||||||
|
|
||||||
|
## [1.124.0] - 2026-09-12
|
||||||
|
|
||||||
|
**`CREDENTIAL_ENCRYPTION_KEY` wurde vor dem Deploy in der Produktions-`.env` gesetzt.**
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **Apple-/Synology-Kalenderpasswörter und OAuth-Tokens werden jetzt verschlüsselt
|
- **Apple-/Synology-Kalenderpasswörter und OAuth-Tokens werden jetzt verschlüsselt
|
||||||
|
|||||||
@ -43,10 +43,40 @@ const nextConfig = {
|
|||||||
serverComponentsExternalPackages: ['ical.js', 'tsdav'],
|
serverComponentsExternalPackages: ['ical.js', 'tsdav'],
|
||||||
},
|
},
|
||||||
// Standard-Sicherheitsheader, die von Scannern (securityheaders.com, Mozilla Observatory,
|
// Standard-Sicherheitsheader, die von Scannern (securityheaders.com, Mozilla Observatory,
|
||||||
// OWASP ZAP Baseline) sonst als fehlend markiert werden. Bewusst ohne Content-Security-Policy:
|
// OWASP ZAP Baseline) sonst als fehlend markiert werden.
|
||||||
// die brauchte eine sorgfaeltige Whitelist (Google/Microsoft-OAuth, Google Fonts, FontAwesome-
|
//
|
||||||
// CDN, inline Styles in WeeklyView) und eigene Tests, um nichts zu zerbrechen -- eigener Schritt.
|
// Content-Security-Policy laeuft bewusst nur als Report-Only: sie blockiert nichts, meldet
|
||||||
|
// aber ueber /api/csp-report, was eine scharfe Policy blockieren wuerde. Die Whitelist ist
|
||||||
|
// aus dem tatsaechlichen Code abgeleitet (12.09.2026):
|
||||||
|
// - script-src/style-src 'unsafe-inline': Next.js' App-Router-Hydration-Skripte und der
|
||||||
|
// eine dangerouslySetInnerHTML-Block in WeeklyView.tsx (View-Transition-Styles) brauchen
|
||||||
|
// das ohne Nonce-Infrastruktur (eigener, groesserer Umbau via Middleware).
|
||||||
|
// - style-src/font-src fonts.googleapis.com/fonts.gstatic.com: FontPicker.tsx und
|
||||||
|
// WeeklyView.tsx laden Nutzer-Schriftarten zur Laufzeit per <link>-Tag nach.
|
||||||
|
// - Keine weiteren externen Domains gefunden: kein <iframe>, keine Bilder von
|
||||||
|
// Drittanbietern (Profilbilder werden nirgends gerendert), keine Client-seitigen
|
||||||
|
// fetch()-Aufrufe zu Fremd-Domains (Notion-OAuth laeuft serverseitig in einer API-Route).
|
||||||
|
// Erst nach ein paar Tagen Reports pruefen -- vor allem die OAuth-Login-Flows (Google/
|
||||||
|
// Microsoft/Apple/Notion) liessen sich hier nicht interaktiv durchklicken -- scharf schalten:
|
||||||
|
// "-Report-Only" aus dem Header-Namen entfernen und report-uri optional behalten.
|
||||||
async headers() {
|
async headers() {
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
"worker-src 'self'",
|
||||||
|
"report-uri /api/csp-report",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
@ -55,6 +85,7 @@ const nextConfig = {
|
|||||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
{ key: 'Content-Security-Policy-Report-Only', value: csp },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.124.0",
|
"version": "1.125.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
32
src/app/api/csp-report/route.ts
Normal file
32
src/app/api/csp-report/route.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { rateLimit, clientIp } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelstelle für Content-Security-Policy-Verstöße.
|
||||||
|
*
|
||||||
|
* Solange die Policy nur als `Content-Security-Policy-Report-Only` läuft (siehe
|
||||||
|
* next.config.js), blockiert sie nichts -- sie meldet nur, was eine scharfe Policy
|
||||||
|
* blockieren würde. Ohne diesen Endpunkt läge das nur im Browser-DevTools jedes einzelnen
|
||||||
|
* Nutzers, nirgends zusammengeführt. Bewusst ungeschützt (Browser rufen das ohne Session
|
||||||
|
* auf) und bewusst großzügig geloggt, bis genug reale Daten für eine scharfe Policy da sind.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Browser können das bei einem verbreiteten Verstoß massenhaft gleichzeitig auslösen --
|
||||||
|
// pro IP drosseln, damit das nicht die Logs flutet oder als DoS-Vektor auf diese Route
|
||||||
|
// selbst missbraucht werden kann.
|
||||||
|
const limit = rateLimit(`csp-report:${clientIp(request)}`, 20, 60 * 1000);
|
||||||
|
if (!limit.ok) return new NextResponse(null, { status: 204 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.text();
|
||||||
|
if (body) {
|
||||||
|
console.warn('[CSP-REPORT]', body.slice(0, 4000));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformte Reports sind kein Fehlerfall, den ein Client je sehen muss.
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@ -9,7 +9,11 @@ export async function middleware(request: NextRequest) {
|
|||||||
request.nextUrl.pathname.startsWith('/_next') ||
|
request.nextUrl.pathname.startsWith('/_next') ||
|
||||||
request.nextUrl.pathname === '/favicon.ico' ||
|
request.nextUrl.pathname === '/favicon.ico' ||
|
||||||
request.nextUrl.pathname === '/sw.js' ||
|
request.nextUrl.pathname === '/sw.js' ||
|
||||||
request.nextUrl.pathname === '/manifest.webmanifest'
|
request.nextUrl.pathname === '/manifest.webmanifest' ||
|
||||||
|
// Der Browser schickt CSP-Verstoss-Reports automatisch, auch von der (nicht
|
||||||
|
// angemeldeten) Login-Seite -- ohne Ausnahme wuerde die Middleware sie zur
|
||||||
|
// Login-Seite umleiten statt an den Report-Endpunkt.
|
||||||
|
request.nextUrl.pathname === '/api/csp-report'
|
||||||
) {
|
) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user