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.
|
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { hash, Algorithm } from '@node-rs/argon2'
|
import { hash } from '@node-rs/argon2'
|
||||||
import { createCipheriv, randomBytes } from 'node:crypto'
|
import { createCipheriv, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
const db = new PrismaClient()
|
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 = {
|
const ARGON2 = {
|
||||||
algorithm: Algorithm.Argon2id,
|
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
export const runtime = 'nodejs'
|
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 (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de" suppressHydrationWarning>
|
||||||
<head>
|
<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>
|
</head>
|
||||||
<body className="min-h-dvh bg-background text-foreground">{children}</body>
|
<body className="min-h-dvh bg-background text-foreground">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+28
-7
@@ -177,6 +177,22 @@ export const VERSCHLUESSELTE_FELDER = {
|
|||||||
|
|
||||||
export type VerschluesseltesModell = keyof typeof 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
|
* Nimmt ein Objekt mit Klartextfeldern (ohne Suffix) und liefert die
|
||||||
* verschluesselten Felder (mit Suffix). Beispiel:
|
* verschluesselten Felder (mit Suffix). Beispiel:
|
||||||
@@ -209,17 +225,22 @@ export function sealen(
|
|||||||
* Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck-
|
* Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck-
|
||||||
* geschrieben wird.
|
* geschrieben wird.
|
||||||
*/
|
*/
|
||||||
export function oeffnen<T extends Record<string, unknown>>(
|
export function oeffnen<M extends VerschluesseltesModell, T extends Record<string, unknown>>(
|
||||||
modell: VerschluesseltesModell,
|
modell: M,
|
||||||
datensatz: T,
|
datensatz: T,
|
||||||
): T & Record<string, string | null> {
|
): T & Klartextfelder<M> {
|
||||||
const ergebnis = { ...datensatz } as T & Record<string, string | null>
|
// 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[]) {
|
for (const feld of VERSCHLUESSELTE_FELDER[modell] as readonly string[]) {
|
||||||
if (!(feld in datensatz)) continue
|
|
||||||
const klarname = feld.replace(/Verschluesselt$/, '')
|
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. */
|
/** Konstantzeit-Vergleich zweier Zeichenketten. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'server-only'
|
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 { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
|
|
||||||
@@ -13,8 +13,12 @@ import { env } from '@/lib/env'
|
|||||||
* lassen sich also ohne Migration einfuehren (siehe hashVeraltet).
|
* lassen sich also ohne Migration einfuehren (siehe hashVeraltet).
|
||||||
*/
|
*/
|
||||||
const ARGON2_OPTIONEN = {
|
const ARGON2_OPTIONEN = {
|
||||||
algorithm: Algorithm.Argon2id,
|
// Der Algorithmus wird bewusst nicht gesetzt: @node-rs/argon2 benutzt
|
||||||
memoryCost: 19_456, // KiB = 19 MiB
|
// 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,
|
timeCost: 2,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
} as const
|
} 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,
|
* Google, kein CDN, keine Analytics. Was nicht aus dieser Anwendung kommt,
|
||||||
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
|
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
|
||||||
*
|
*
|
||||||
* 'strict-dynamic' erlaubt es dem Next.js-Loader, weitere eigene Skripte
|
* Im Produktivbetrieb gilt 'strict-dynamic': Next.js haengt die Nonce an
|
||||||
* nachzuladen, ohne dass jede einzelne Datei aufgelistet werden muss.
|
* 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,
|
* - style-src erlaubt 'unsafe-inline'. Tailwind selbst braucht das nicht,
|
||||||
* aber Next.js und Radix setzen Inline-Styles fuer Positionierung. Das
|
* aber Next.js und Radix setzen Inline-Styles fuer Positionierung. Das
|
||||||
* Risiko ist bei Styles deutlich kleiner als bei Skripten.
|
* Risiko ist bei Styles deutlich kleiner als bei Skripten.
|
||||||
* - img-src erlaubt data: - QR-Codes und Belegvorschauen werden als
|
* - img-src erlaubt data: - QR-Codes und Belegvorschauen werden als
|
||||||
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
|
* 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. */
|
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */
|
||||||
@@ -43,7 +51,9 @@ export function middleware(anfrage: NextRequest) {
|
|||||||
|
|
||||||
const richtlinie = [
|
const richtlinie = [
|
||||||
`default-src 'self'`,
|
`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'`,
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
`img-src 'self' blob: data:`,
|
`img-src 'self' blob: data:`,
|
||||||
`font-src 'self'`,
|
`font-src 'self'`,
|
||||||
|
|||||||
Reference in New Issue
Block a user