waffensachkunde

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

/ app src main sicherheit.ts

7,3 KB Rohdatei
app/src/main/sicherheit.ts — 191 Zeilen
1 /**
2 * Zentrale Härtung der Electron-Laufzeit.
3 *
4 * Alles hier ist bewusst „standardmäßig verboten“: Berechtigungen werden
5 * abgelehnt, Navigation nach außen wird unterbunden, neue Fenster werden nicht
6 * geöffnet, sondern an den Standardbrowser des Systems übergeben.
7 */
8
9 import { app, shell, session, type Session } from 'electron';
10
11 import { cspRichtlinie, type CspModus } from '../shared/csp';
12 import { UNTERSTUETZUNG_URL, unterstuetzungsziel } from '../shared/unterstuetzung';
13 import { istGleicheDatei, oberflaechenUrl } from './oberflaeche';
14
15 /** Herkünfte, zu denen der Renderer navigieren darf. */
16 function erlaubteHerkuenfte(): readonly string[] {
17 const dev = process.env['ELECTRON_RENDERER_URL'];
18 return dev ? [new URL(dev).origin] : [];
19 }
20
21 /**
22 * Darf der Renderer dorthin navigieren?
23 *
24 * Bei `file:` genügt das Protokoll ausdrücklich **nicht**. Sonst käme jede
25 * Datei auf der Platte in Frage – etwa eine heruntergeladene HTML-Datei. Das
26 * Preload-Skript hängt am `webContents` und würde auch für die neue Seite
27 * wieder eingespielt: Die fremde Seite hätte die volle Bridge in der Hand.
28 * Verglichen wird deshalb der aufgelöste Pfad gegen die eine Datei, die
29 * tatsächlich geladen wird.
30 *
31 * Hash und Query bleiben dabei außer Betracht – Sprungziele innerhalb der
32 * eigenen Seite sind unbedenklich.
33 */
34 export function istErlaubteNavigation(ziel: string): boolean {
35 let url: URL;
36 try {
37 url = new URL(ziel);
38 } catch {
39 return false;
40 }
41
42 if (url.protocol === 'file:') {
43 return istGleicheDatei(ziel, oberflaechenUrl());
44 }
45
46 return erlaubteHerkuenfte().includes(url.origin);
47 }
48
49 /**
50 * Setzt die Content-Security-Policy als HTTP-Kopf.
51 *
52 * Nicht nur für den Dev-Server, wie hier früher stand: `onHeadersReceived`
53 * greift auch für `file://`-Antworten – nachgemessen am gebauten Programm,
54 * die Rückrufe kamen für index.html, das JS-Bündel und das Stilblatt. Damit
55 * ist dieser Weg auch im gepackten Zustand der einzige, über den
56 * `frame-ancestors` überhaupt wirken kann; im `<meta>`-Element ignoriert
57 * Chromium die Direktive (siehe `src/shared/csp.ts`).
58 *
59 * Beide Richtlinien gelten nebeneinander, ausgewertet wird die Schnittmenge.
60 */
61 function cspHeaderSetzen(zielSession: Session, modus: CspModus): void {
62 const richtlinie = cspRichtlinie(modus, 'kopf');
63
64 zielSession.webRequest.onHeadersReceived((details, callback) => {
65 // Vorhandene CSP-Header werden nicht ergänzt, sondern ersetzt: mehrere
66 // Richtlinien würden als Schnittmenge ausgewertet und im Dev-Modus
67 // unbemerkt zu Fehlern führen.
68 const header = Object.fromEntries(
69 Object.entries(details.responseHeaders ?? {}).filter(
70 ([schluessel]) => schluessel.toLowerCase() !== 'content-security-policy',
71 ),
72 );
73 header['Content-Security-Policy'] = [richtlinie];
74
75 callback({ responseHeaders: header });
76 });
77 }
78
79 /** Lehnt sämtliche Geräte- und Medienberechtigungen ab. */
80 function berechtigungenSperren(zielSession: Session): void {
81 zielSession.setPermissionRequestHandler((_webContents, berechtigung, callback) => {
82 console.warn(`[sicherheit] Berechtigung abgelehnt: ${berechtigung}`);
83 callback(false);
84 });
85
86 zielSession.setPermissionCheckHandler(() => false);
87
88 zielSession.setDevicePermissionHandler(() => false);
89 }
90
91 /** Verhindert Navigation nach außen und das Öffnen fremder Fenster. */
92 function navigationSperren(): void {
93 app.on('web-contents-created', (_ereignis, inhalte) => {
94 inhalte.setWindowOpenHandler(({ url }) => {
95 // Externe Links bewusst im Systembrowser öffnen, nie in einem
96 // Electron-Fenster (das hätte Zugriff auf unsere Preload-Bridge).
97 if (url.startsWith('https://')) {
98 void shell.openExternal(url);
99 }
100 return { action: 'deny' };
101 });
102
103 inhalte.on('will-navigate', (ereignis, url) => {
104 if (!istErlaubteNavigation(url)) {
105 ereignis.preventDefault();
106 console.warn(`[sicherheit] Navigation blockiert: ${url}`);
107 }
108 });
109
110 /* `will-navigate` deckt serverseitige Weiterleitungen nicht ab. Praktisch
111 nur im Entwicklungsbetrieb erreichbar – die Sperre kostet nichts. */
112 inhalte.on('will-redirect', (ereignis, url) => {
113 if (!istErlaubteNavigation(url)) {
114 ereignis.preventDefault();
115 console.warn(`[sicherheit] Weiterleitung blockiert: ${url}`);
116 }
117 });
118
119 inhalte.on('will-attach-webview', (ereignis) => {
120 // <webview> wird nicht verwendet und bleibt deshalb komplett gesperrt.
121 ereignis.preventDefault();
122 });
123 });
124 }
125
126 /**
127 * Öffnet die Unterstützungsseite im Standardbrowser – und sonst nichts.
128 *
129 * ## Warum das hier steht
130 *
131 * Diese Datei ist die Stelle, an der geregelt ist, was nach außen darf. Bis
132 * jetzt stand hier nur ein Verbot: {@link navigationSperren} übergibt neue
133 * Fenster an den Systembrowser, statt sie in Electron zu öffnen. Der
134 * Spendenknopf braucht einen Weg hinaus, der ausdrücklich angestoßen wird –
135 * er gehört deshalb daneben und nicht in ein Fachmodul, wo ihn beim nächsten
136 * Sicherheitsdurchgang niemand suchen würde.
137 *
138 * ## Warum die Adresse trotzdem geprüft wird
139 *
140 * Der Renderer schickt die Adresse mit, damit prüfbar bleibt, dass die
141 * Oberfläche genau das anfordert, was sie anzeigt. Geöffnet wird darum aber
142 * nicht, was ankommt, sondern ausschließlich die eine eingetragene Adresse:
143 * `shell.openExternal` reicht an das Betriebssystem weiter, und was dort
144 * ankommt, ist nicht mehr einzufangen. Käme fremder Code doch einmal in den
145 * Renderer, wäre dies sonst der bequemste Ausgang, den er finden könnte.
146 *
147 * Ist keine Adresse eingetragen, öffnet dieser Weg nichts, und die Oberfläche
148 * zeigt das Angebot dann ohnehin nicht an. Das war bis 0.21.1 der Zustand;
149 * seit 0.22.0 führt `shared/unterstuetzung.ts` eine Adresse, und der
150 * Kommentar behauptete bis 0.24.2 weiter das Gegenteil – ein Satz, der beim
151 * Lesen des Bausteins die Lage falsch darstellte. Die Zusage darunter bleibt
152 * dieselbe: Geöffnet wird ausschließlich die eine eingetragene Adresse.
153 *
154 * @returns `true`, wenn der Browser angestoßen wurde.
155 */
156 export async function unterstuetzungOeffnen(gewuenscht: unknown): Promise<boolean> {
157 const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL);
158 if (ziel === null) {
159 console.warn('[sicherheit] Unterstützungsseite nicht geöffnet: keine Adresse eingetragen.');
160 return false;
161 }
162
163 /* Verglichen wird die Zeichenkette, nicht ein wieder zusammengesetzter
164 URL-Wert: Jede Umformung dazwischen wäre eine Stelle, an der zwei
165 Schreibweisen als gleich durchgingen. Die gewünschte Adresse wird
166 bewusst nicht mitprotokolliert – ins Protokoll gehört, dass etwas
167 abgewiesen wurde, nicht der fremde Text selbst. */
168 if (gewuenscht !== ziel) {
169 console.warn('[sicherheit] Außenverweis abgewiesen: nicht die eingetragene Adresse.');
170 return false;
171 }
172
173 try {
174 await shell.openExternal(ziel);
175 return true;
176 } catch (fehler) {
177 console.warn(`[sicherheit] Der Standardbrowser ließ sich nicht öffnen: ${String(fehler)}`);
178 return false;
179 }
180 }
181
182 /**
183 * Muss NACH `app.whenReady()` aufgerufen werden (benötigt die Default-Session).
184 */
185 export function sicherheitEinrichten(modus: CspModus): void {
186 const standardSession = session.defaultSession;
187
188 cspHeaderSetzen(standardSession, modus);
189 berechtigungenSperren(standardSession);
190 navigationSperren();
191 }