fix: Typisierung von oeffnen(), CSP im Entwicklungsmodus, Nonce-Abweichung

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
This commit is contained in:
Florian Wilde
2026-08-22 06:06:20 +02:00
parent 3833a9dd8f
commit 1234a57fa6
6 changed files with 68 additions and 19 deletions
+3 -2
View File
@@ -23,13 +23,14 @@
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/
import { PrismaClient } from '@prisma/client'
import { hash, Algorithm } from '@node-rs/argon2'
import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto'
const db = new PrismaClient()
// Muss zu ARGON2_OPTIONEN in src/lib/crypto/hash.ts passen.
// Ohne Angabe benutzt @node-rs/argon2 von sich aus Argon2id.
const ARGON2 = {
algorithm: Algorithm.Argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
+3 -1
View File
@@ -6,4 +6,6 @@
*/
export const runtime = 'nodejs'
export { GET, POST } from '@/auth'
import { handlers } from '@/auth'
export const { GET, POST } = handlers
+12 -1
View File
@@ -57,7 +57,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="de" suppressHydrationWarning>
<head>
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }} />
{/*
suppressHydrationWarning ist hier noetig: React gibt das
nonce-Attribut bewusst nicht an den Browser weiter (es soll nicht
im DOM auslesbar sein). Serverseitig steht es im HTML, clientseitig
ist es leer - und genau das meldet React sonst bei jedem Seiten-
aufruf als Abweichung.
*/}
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: THEMA_SKRIPT }}
/>
</head>
<body className="min-h-dvh bg-background text-foreground">{children}</body>
</html>
+28 -7
View File
@@ -177,6 +177,22 @@ export const VERSCHLUESSELTE_FELDER = {
export type VerschluesseltesModell = keyof typeof VERSCHLUESSELTE_FELDER
/** Aus "diagnosenVerschluesselt" wird "diagnosen". */
type OhneSuffix<F extends string> = F extends `${infer Name}Verschluesselt` ? Name : never
/**
* Die Klartextfelder eines Modells als benannte Eigenschaften.
*
* Ohne diesen Typ waere das Ergebnis von oeffnen() nur ein
* Record<string, string | null>. Mit noUncheckedIndexedAccess bekaeme
* jeder Zugriff darauf zusaetzlich "undefined" - und jede aufrufende
* Seite muesste das wegkaesen, obwohl die Felder tatsaechlich immer da
* sind.
*/
export type Klartextfelder<M extends VerschluesseltesModell> = {
[K in OhneSuffix<(typeof VERSCHLUESSELTE_FELDER)[M][number]>]: string | null
}
/**
* Nimmt ein Objekt mit Klartextfeldern (ohne Suffix) und liefert die
* verschluesselten Felder (mit Suffix). Beispiel:
@@ -209,17 +225,22 @@ export function sealen(
* Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck-
* geschrieben wird.
*/
export function oeffnen<T extends Record<string, unknown>>(
modell: VerschluesseltesModell,
export function oeffnen<M extends VerschluesseltesModell, T extends Record<string, unknown>>(
modell: M,
datensatz: T,
): T & Record<string, string | null> {
const ergebnis = { ...datensatz } as T & Record<string, string | null>
): T & Klartextfelder<M> {
// Zwischenschritt ueber ein einfaches Objekt: ein generischer Typ laesst
// sich in TypeScript nicht per Index beschreiben.
const ergebnis: Record<string, unknown> = { ...datensatz }
for (const feld of VERSCHLUESSELTE_FELDER[modell] as readonly string[]) {
if (!(feld in datensatz)) continue
const klarname = feld.replace(/Verschluesselt$/, '')
ergebnis[klarname] = entschluesselnWennMoeglich(datensatz[feld] as string | null, feld)
ergebnis[klarname] = (feld in datensatz)
? entschluesselnWennMoeglich(datensatz[feld] as string | null, feld)
: null
}
return ergebnis
return ergebnis as T & Klartextfelder<M>
}
/** Konstantzeit-Vergleich zweier Zeichenketten. */
+7 -3
View File
@@ -1,5 +1,5 @@
import 'server-only'
import { hash, verify, Algorithm } from '@node-rs/argon2'
import { hash, verify } from '@node-rs/argon2'
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { env } from '@/lib/env'
@@ -13,8 +13,12 @@ import { env } from '@/lib/env'
* lassen sich also ohne Migration einfuehren (siehe hashVeraltet).
*/
const ARGON2_OPTIONEN = {
algorithm: Algorithm.Argon2id,
memoryCost: 19_456, // KiB = 19 MiB
// Der Algorithmus wird bewusst nicht gesetzt: @node-rs/argon2 benutzt
// von sich aus Argon2id. Die Konstante Algorithm.Argon2id ist ein
// "const enum" und laesst sich mit isolatedModules nicht importieren -
// eine hart geschriebene 2 waere die Alternative, aber die sagt beim
// Lesen nichts.
memoryCost: 19_456, // KiB = 19 MiB, OWASP-Empfehlung Stand 2024
timeCost: 2,
parallelism: 1,
} as const
+15 -5
View File
@@ -10,16 +10,24 @@ import { NextResponse, type NextRequest } from 'next/server'
* Google, kein CDN, keine Analytics. Was nicht aus dieser Anwendung kommt,
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
*
* 'strict-dynamic' erlaubt es dem Next.js-Loader, weitere eigene Skripte
* nachzuladen, ohne dass jede einzelne Datei aufgelistet werden muss.
* 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.
*
* Bewusste Zugestaendnisse:
* 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.
* - Im Entwicklungsmodus braucht der Hot-Reload 'unsafe-eval'.
*/
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */
@@ -43,7 +51,9 @@ export function middleware(anfrage: NextRequest) {
const richtlinie = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${istEntwicklung ? " 'unsafe-eval'" : ''}`,
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'`,