/**
* 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();
}