waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src shared csp.ts

4,9 KB Rohdatei
app/src/shared/csp.ts — 108 Zeilen
1 /**
2 * Content-Security-Policy – eine einzige Quelle der Wahrheit.
3 *
4 * Die Richtlinie erreicht den Renderer auf zwei Wegen, und beide gelten
5 * gleichzeitig. Chromium wertet mehrere Richtlinien nebeneinander aus, jede
6 * für sich; erlaubt bleibt nur, was allen genügt – die Schnittmenge.
7 *
8 * 1. als `<meta http-equiv="Content-Security-Policy">` im Renderer-HTML
9 * (eingesetzt beim Bau, siehe `cspPlugin` in `electron.vite.config.ts`),
10 * 2. als HTTP-Kopf über `onHeadersReceived` im Main-Prozess
11 * (siehe `cspHeaderSetzen` in `src/main/sicherheit.ts`).
12 *
13 * ## Warum es zwei Fassungen gibt
14 *
15 * Drei Direktiven ignoriert jeder Browser, wenn sie über ein `<meta>`-Element
16 * kommen: `frame-ancestors`, `report-uri` und `sandbox`. Sie richten sich an
17 * den einbettenden Kontext bzw. an den Ladevorgang selbst – beides steht fest,
18 * bevor das HTML geparst ist. Chromium schreibt dann bei jedem Start einen
19 * Konsolenfehler: „The Content Security Policy directive 'frame-ancestors' is
20 * ignored when delivered via a <meta> element.“
21 *
22 * Genau das tat dieses Programm, weil `frame-ancestors 'none'` in beiden
23 * Fassungen stand. Eine Direktive, die dasteht und nicht gilt, ist schlimmer
24 * als keine: Zwei Tests prüften ihren Wortlaut und meldeten grün.
25 *
26 * Die `<meta>`-Fassung lässt diese Direktiven deshalb weg ({@link NUR_IM_KOPF}).
27 * Im Kopf bleiben sie stehen – dort gelten sie.
28 *
29 * ## Warum der Schutz dabei nicht verloren geht – nachgemessen
30 *
31 * Hier stand früher, der Kopf-Weg sei nur für den Vite-Dev-Server da und
32 * `file://` hänge allein am `<meta>`-Element. Das stimmt nicht:
33 * `onHeadersReceived` greift in Electron auch für `file://`-Antworten.
34 *
35 * Nachgemessen am gebauten Programm (`out/main/index.js`, Electron 43): Ein
36 * absichtlich ausgelöster Verstoß gegen `img-src` meldete **zwei**
37 * `securitypolicyviolation`-Ereignisse – eines mit dem Wortlaut der
38 * Kopf-Fassung, eines mit dem der `<meta>`-Fassung. Der Kopf kommt also im
39 * Renderer an, und mit ihm `frame-ancestors`. Dieselbe Messung führt
40 * `e2e/anwendung.spec.ts` bei jedem Lauf im echten Fenster durch, damit die
41 * Zusicherung nicht wieder zur Behauptung wird.
42 *
43 * ## Beide Fassungen dürfen sonst nicht auseinanderlaufen
44 *
45 * Wäre die Kopf-Fassung strenger als die `<meta>`-Fassung, bräche der
46 * Dev-Modus unbemerkt an der Schnittmenge. `tests/sicherheit.test.ts` hält
47 * deshalb fest: `<meta>`-Fassung == Kopf-Fassung ohne die Nur-Kopf-Direktiven,
48 * Zeichen für Zeichen.
49 */
50
51 export type CspModus = 'development' | 'production';
52
53 /** Über welchen Weg die Richtlinie ausgeliefert wird. */
54 export type CspZiel = 'meta' | 'kopf';
55
56 /**
57 * Direktiven, die ausschließlich als HTTP-Kopf wirken.
58 *
59 * Über ein `<meta>`-Element werden sie ignoriert – mit Konsolenfehler. Wer
60 * hier etwas ergänzt, nimmt es damit zugleich aus der `<meta>`-Fassung heraus.
61 * Vollständig laut CSP Level 3: `frame-ancestors`, `report-uri`, `sandbox`.
62 * Aufgeführt sind nur die, die diese Anwendung tatsächlich setzt.
63 */
64 const NUR_IM_KOPF: readonly string[] = ['frame-ancestors'];
65
66 /**
67 * Im Produktionsbuild gilt eine strenge Richtlinie ohne `unsafe-*`.
68 * Im Dev-Modus sind zusätzlich nötig:
69 * - `'unsafe-inline'` für Skripte: Vite injiziert die React-Refresh-Präambel
70 * als Inline-Skript in das HTML.
71 * - `'unsafe-inline'` für Styles: HMR fügt `<style>`-Elemente zur Laufzeit ein.
72 * - `ws:` in `connect-src` für den HMR-WebSocket.
73 *
74 * `ziel` ist mit Absicht ein Pflichtargument: Jede Aufrufstelle muss sagen,
75 * über welchen Weg sie ausliefert – ein stiller Vorgabewert hätte die
76 * `<meta>`-Fassung genau so wieder mit `frame-ancestors` beliefert.
77 */
78 export function cspRichtlinie(modus: CspModus, ziel: CspZiel): string {
79 const entwicklung = modus === 'development';
80
81 const direktiven: Record<string, readonly string[]> = {
82 'default-src': ["'none'"],
83 'script-src': entwicklung ? ["'self'", "'unsafe-inline'"] : ["'self'"],
84 'style-src': entwicklung ? ["'self'", "'unsafe-inline'"] : ["'self'"],
85 'img-src': ["'self'", 'data:'],
86 'font-src': ["'self'"],
87 'media-src': ["'self'"],
88 'connect-src': entwicklung ? ["'self'", 'ws:', 'http://localhost:*'] : ["'self'"],
89 'worker-src': ["'self'"],
90 'manifest-src': ["'self'"],
91 'base-uri': ["'none'"],
92 'form-action': ["'none'"],
93 /* Wirkt auch als `<meta>`: verbietet der eigenen Seite, irgendetwas
94 einzubetten. Nicht zu verwechseln mit `frame-ancestors`, das umgekehrt
95 das Eingebettetwerden verbietet – und nur im Kopf gilt. */
96 'frame-src': ["'none'"],
97 'frame-ancestors': ["'none'"],
98 'object-src': ["'none'"],
99 };
100
101 return Object.entries(direktiven)
102 .filter(([direktive]) => ziel === 'kopf' || !NUR_IM_KOPF.includes(direktive))
103 .map(([direktive, werte]) => `${direktive} ${werte.join(' ')}`)
104 .join('; ');
105 }
106
107 /** Platzhalter in `src/renderer/index.html`, den das Vite-Plugin ersetzt. */
108 export const CSP_PLATZHALTER = '%CSP%';