/** * Zentrale Härtung der Electron-Laufzeit. * * Alles hier ist bewusst „standardmäßig verboten“: Berechtigungen werden * abgelehnt, Navigation nach außen wird unterbunden, neue Fenster werden nicht * geöffnet, sondern an den Standardbrowser des Systems übergeben. */ import { app, shell, session, type Session } from 'electron'; import { cspRichtlinie, type CspModus } from '../shared/csp'; import { UNTERSTUETZUNG_URL, unterstuetzungsziel } from '../shared/unterstuetzung'; import { istGleicheDatei, oberflaechenUrl } from './oberflaeche'; /** Herkünfte, zu denen der Renderer navigieren darf. */ function erlaubteHerkuenfte(): readonly string[] { const dev = process.env['ELECTRON_RENDERER_URL']; return dev ? [new URL(dev).origin] : []; } /** * Darf der Renderer dorthin navigieren? * * Bei `file:` genügt das Protokoll ausdrücklich **nicht**. Sonst käme jede * Datei auf der Platte in Frage – etwa eine heruntergeladene HTML-Datei. Das * Preload-Skript hängt am `webContents` und würde auch für die neue Seite * wieder eingespielt: Die fremde Seite hätte die volle Bridge in der Hand. * Verglichen wird deshalb der aufgelöste Pfad gegen die eine Datei, die * tatsächlich geladen wird. * * Hash und Query bleiben dabei außer Betracht – Sprungziele innerhalb der * eigenen Seite sind unbedenklich. */ export function istErlaubteNavigation(ziel: string): boolean { let url: URL; try { url = new URL(ziel); } catch { return false; } if (url.protocol === 'file:') { return istGleicheDatei(ziel, oberflaechenUrl()); } return erlaubteHerkuenfte().includes(url.origin); } /** * Setzt die Content-Security-Policy als HTTP-Kopf. * * Nicht nur für den Dev-Server, wie hier früher stand: `onHeadersReceived` * greift auch für `file://`-Antworten – nachgemessen am gebauten Programm, * die Rückrufe kamen für index.html, das JS-Bündel und das Stilblatt. Damit * ist dieser Weg auch im gepackten Zustand der einzige, über den * `frame-ancestors` überhaupt wirken kann; im ``-Element ignoriert * Chromium die Direktive (siehe `src/shared/csp.ts`). * * Beide Richtlinien gelten nebeneinander, ausgewertet wird die Schnittmenge. */ function cspHeaderSetzen(zielSession: Session, modus: CspModus): void { const richtlinie = cspRichtlinie(modus, 'kopf'); zielSession.webRequest.onHeadersReceived((details, callback) => { // Vorhandene CSP-Header werden nicht ergänzt, sondern ersetzt: mehrere // Richtlinien würden als Schnittmenge ausgewertet und im Dev-Modus // unbemerkt zu Fehlern führen. const header = Object.fromEntries( Object.entries(details.responseHeaders ?? {}).filter( ([schluessel]) => schluessel.toLowerCase() !== 'content-security-policy', ), ); header['Content-Security-Policy'] = [richtlinie]; callback({ responseHeaders: header }); }); } /** Lehnt sämtliche Geräte- und Medienberechtigungen ab. */ function berechtigungenSperren(zielSession: Session): void { zielSession.setPermissionRequestHandler((_webContents, berechtigung, callback) => { console.warn(`[sicherheit] Berechtigung abgelehnt: ${berechtigung}`); callback(false); }); zielSession.setPermissionCheckHandler(() => false); zielSession.setDevicePermissionHandler(() => false); } /** Verhindert Navigation nach außen und das Öffnen fremder Fenster. */ function navigationSperren(): void { app.on('web-contents-created', (_ereignis, inhalte) => { inhalte.setWindowOpenHandler(({ url }) => { // Externe Links bewusst im Systembrowser öffnen, nie in einem // Electron-Fenster (das hätte Zugriff auf unsere Preload-Bridge). if (url.startsWith('https://')) { void shell.openExternal(url); } return { action: 'deny' }; }); inhalte.on('will-navigate', (ereignis, url) => { if (!istErlaubteNavigation(url)) { ereignis.preventDefault(); console.warn(`[sicherheit] Navigation blockiert: ${url}`); } }); /* `will-navigate` deckt serverseitige Weiterleitungen nicht ab. Praktisch nur im Entwicklungsbetrieb erreichbar – die Sperre kostet nichts. */ inhalte.on('will-redirect', (ereignis, url) => { if (!istErlaubteNavigation(url)) { ereignis.preventDefault(); console.warn(`[sicherheit] Weiterleitung blockiert: ${url}`); } }); inhalte.on('will-attach-webview', (ereignis) => { // wird nicht verwendet und bleibt deshalb komplett gesperrt. ereignis.preventDefault(); }); }); } /** * Öffnet die Unterstützungsseite im Standardbrowser – und sonst nichts. * * ## Warum das hier steht * * Diese Datei ist die Stelle, an der geregelt ist, was nach außen darf. Bis * jetzt stand hier nur ein Verbot: {@link navigationSperren} übergibt neue * Fenster an den Systembrowser, statt sie in Electron zu öffnen. Der * Spendenknopf braucht einen Weg hinaus, der ausdrücklich angestoßen wird – * er gehört deshalb daneben und nicht in ein Fachmodul, wo ihn beim nächsten * Sicherheitsdurchgang niemand suchen würde. * * ## Warum die Adresse trotzdem geprüft wird * * Der Renderer schickt die Adresse mit, damit prüfbar bleibt, dass die * Oberfläche genau das anfordert, was sie anzeigt. Geöffnet wird darum aber * nicht, was ankommt, sondern ausschließlich die eine eingetragene Adresse: * `shell.openExternal` reicht an das Betriebssystem weiter, und was dort * ankommt, ist nicht mehr einzufangen. Käme fremder Code doch einmal in den * Renderer, wäre dies sonst der bequemste Ausgang, den er finden könnte. * * Ist keine Adresse eingetragen, öffnet dieser Weg nichts, und die Oberfläche * zeigt das Angebot dann ohnehin nicht an. Das war bis 0.21.1 der Zustand; * seit 0.22.0 führt `shared/unterstuetzung.ts` eine Adresse, und der * Kommentar behauptete bis 0.24.2 weiter das Gegenteil – ein Satz, der beim * Lesen des Bausteins die Lage falsch darstellte. Die Zusage darunter bleibt * dieselbe: Geöffnet wird ausschließlich die eine eingetragene Adresse. * * @returns `true`, wenn der Browser angestoßen wurde. */ export async function unterstuetzungOeffnen(gewuenscht: unknown): Promise { const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL); if (ziel === null) { console.warn('[sicherheit] Unterstützungsseite nicht geöffnet: keine Adresse eingetragen.'); return false; } /* Verglichen wird die Zeichenkette, nicht ein wieder zusammengesetzter URL-Wert: Jede Umformung dazwischen wäre eine Stelle, an der zwei Schreibweisen als gleich durchgingen. Die gewünschte Adresse wird bewusst nicht mitprotokolliert – ins Protokoll gehört, dass etwas abgewiesen wurde, nicht der fremde Text selbst. */ if (gewuenscht !== ziel) { console.warn('[sicherheit] Außenverweis abgewiesen: nicht die eingetragene Adresse.'); return false; } try { await shell.openExternal(ziel); return true; } catch (fehler) { console.warn(`[sicherheit] Der Standardbrowser ließ sich nicht öffnen: ${String(fehler)}`); return false; } } /** * Muss NACH `app.whenReady()` aufgerufen werden (benötigt die Default-Session). */ export function sicherheitEinrichten(modus: CspModus): void { const standardSession = session.defaultSession; cspHeaderSetzen(standardSession, modus); berechtigungenSperren(standardSession); navigationSperren(); }