Drei Fehler, die sich beim Durchsehen der Typpruefung und der
Browserkonsole gezeigt haben.
1. oeffnen() lieferte Record<string, string | null>. Zusammen mit
noUncheckedIndexedAccess wurde daraus bei jedem Zugriff
string | null | undefined - und jede aufrufende Seite haette das
wegkaesen muessen, obwohl die Felder immer da sind. Jetzt leitet ein
Mapped Type die Klartextnamen aus VERSCHLUESSELTE_FELDER ab:
oeffnen('Betreute', zeile).diagnosen ist sauber string | null.
Das hat auf einen Schlag 17 Folgefehler in fremden Modulen erledigt.
2. Die CSP blockierte im Entwicklungsmodus die eigenen Skripte.
'strict-dynamic' schaltet die Freigabe nach Herkunft ab, also auch
'self' - und Hot-Reload und Fehleranzeige laden ohne Nonce. Man sah
eine tote Seite statt der Fehlermeldung. Im Produktivbetrieb bleibt
es bei Nonce plus 'strict-dynamic', im Entwicklungsmodus gilt die
lockere Fassung.
3. Das Theme-Skript im Layout meldete bei jedem Seitenaufruf eine
Hydrations-Abweichung: React gibt das nonce-Attribut absichtlich
nicht an den Browser weiter, serverseitig steht es aber im HTML.
suppressHydrationWarning am Skript selbst - das html-Element allein
deckt das nicht ab.
Nebenbei: Algorithm.Argon2id ist ein const enum und laesst sich mit
isolatedModules nicht importieren. Die Angabe entfaellt, @node-rs/argon2
benutzt von sich aus Argon2id.
Die Anmeldeseite laedt jetzt ohne einen einzigen Konsolenfehler.
Etappe: 8
Phase: Haertung
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
import { NextResponse, type NextRequest } from 'next/server'
|
|
|
|
/**
|
|
* Content-Security-Policy mit Nonce.
|
|
*
|
|
* Warum hier und nicht in next.config? Weil die Nonce pro Anfrage neu sein
|
|
* muss. Statische Header koennen das nicht.
|
|
*
|
|
* Die Richtlinie ist bewusst eng: keine fremden Quellen, keine Fonts von
|
|
* Google, kein CDN, keine Analytics. Was nicht aus dieser Anwendung kommt,
|
|
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
|
|
*
|
|
* Im Produktivbetrieb gilt 'strict-dynamic': Next.js haengt die Nonce an
|
|
* seine eigenen Skript-Tags, und diese duerfen dann weitere eigene
|
|
* Skripte nachladen. Kein Fremdskript kommt durch, auch nicht ueber eine
|
|
* eingeschleuste <script src>-Zeile.
|
|
*
|
|
* Im Entwicklungsmodus geht das NICHT: 'strict-dynamic' schaltet die
|
|
* Freigabe nach Herkunft ab, also auch 'self'. Der Hot-Reload und die
|
|
* Fehleranzeige von Next.js laden aber Skripte ohne Nonce
|
|
* (webpack.js, react-refresh.js, das Fehler-Overlay des Pages-Routers).
|
|
* Die wuerden alle blockiert - man saehe eine tote Seite statt der
|
|
* Fehlermeldung. Deshalb hier bewusst die lockere Fassung.
|
|
*
|
|
* Weitere bewusste Zugestaendnisse:
|
|
* - style-src erlaubt 'unsafe-inline'. Tailwind selbst braucht das nicht,
|
|
* aber Next.js und Radix setzen Inline-Styles fuer Positionierung. Das
|
|
* Risiko ist bei Styles deutlich kleiner als bei Skripten.
|
|
* - img-src erlaubt data: - QR-Codes und Belegvorschauen werden als
|
|
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
|
|
*/
|
|
|
|
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */
|
|
const OEFFENTLICHE_PFADE = [
|
|
'/anmelden',
|
|
'/ersteinrichtung',
|
|
'/datenschutz',
|
|
'/impressum',
|
|
'/api/auth',
|
|
]
|
|
|
|
function nonceErzeugen(): string {
|
|
const bytes = new Uint8Array(16)
|
|
crypto.getRandomValues(bytes)
|
|
return btoa(String.fromCharCode(...bytes))
|
|
}
|
|
|
|
export function middleware(anfrage: NextRequest) {
|
|
const nonce = nonceErzeugen()
|
|
const istEntwicklung = process.env.NODE_ENV === 'development'
|
|
|
|
const richtlinie = [
|
|
`default-src 'self'`,
|
|
istEntwicklung
|
|
? `script-src 'self' 'unsafe-inline' 'unsafe-eval'`
|
|
: `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
`style-src 'self' 'unsafe-inline'`,
|
|
`img-src 'self' blob: data:`,
|
|
`font-src 'self'`,
|
|
// Uploads und Server Actions gehen an die eigene Herkunft. Im
|
|
// Entwicklungsmodus zusaetzlich der WebSocket des Hot-Reloads.
|
|
`connect-src 'self'${istEntwicklung ? ' ws: wss:' : ''}`,
|
|
`media-src 'self' blob:`,
|
|
`object-src 'none'`,
|
|
`base-uri 'self'`,
|
|
`form-action 'self'`,
|
|
`frame-ancestors 'none'`,
|
|
`frame-src 'self' blob:`,
|
|
`worker-src 'self' blob:`,
|
|
`manifest-src 'self'`,
|
|
...(istEntwicklung ? [] : ['upgrade-insecure-requests']),
|
|
].join('; ')
|
|
|
|
const kopf = new Headers(anfrage.headers)
|
|
kopf.set('x-nonce', nonce)
|
|
|
|
const antwort = NextResponse.next({ request: { headers: kopf } })
|
|
antwort.headers.set('Content-Security-Policy', richtlinie)
|
|
|
|
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
|
|
// zwischengespeichert wird.
|
|
if (!OEFFENTLICHE_PFADE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) {
|
|
antwort.headers.set('Cache-Control', 'no-store, must-revalidate')
|
|
}
|
|
|
|
return antwort
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Alles ausser statischen Dateien und dem Favicon. Der negative
|
|
* Lookahead haelt die Middleware von Auslieferungspfaden fern, wo sie
|
|
* nur Rechenzeit kosten wuerde.
|
|
*/
|
|
{
|
|
source: '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|webp|ico|woff2?)$).*)',
|
|
missing: [
|
|
{ type: 'header', key: 'next-router-prefetch' },
|
|
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
|
],
|
|
},
|
|
],
|
|
}
|