waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |