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:
+3
-2
@@ -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,
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
*/
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export { GET, POST } from '@/auth'
|
||||
import { handlers } from '@/auth'
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
+12
-1
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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'`,
|
||||
|
||||
Reference in New Issue
Block a user