Symptom: Die installierte App zeigte "Keine Verbindung", obwohl die Seite online war, der Server lief und jede Pruefung Erfolg meldete. Ursache war meine eigene Zeile von heute Vormittag. Fuer alles, was keine Seite ist, setzte die Middleware "default-src 'none'" -- mit dem Gedanken "kostet nichts und schadet nie". Der zweite Halbsatz war falsch. Ein Service Worker uebernimmt die Inhaltsrichtlinie, die beim Herunterladen SEINER EIGENEN Skriptdatei gesetzt war, nicht die der Seite, fuer die er arbeitet. sw.js ist keine Seite, bekam also 'none' und durfte damit nichts mehr abrufen. Jede Anfrage scheiterte -- und weil der Service Worker fuer genau diesen Fall eine Offline-Seite bereithaelt, sah es aus wie ein Netzausfall beim Benutzer. Fuer Bilder, Stylesheets und Schriften bringt eine Richtlinie ohnehin nichts: Sie steuert, was ein DOKUMENT nachladen darf. Ein Bild laedt nichts nach. Dem Schaden stand also nie ein Gewinn gegenueber. Jetzt: Richtlinie nur noch fuer HTML-Dokumente. Warum der Test das nicht gefunden hat, folgt gleich -- er hat die Seiten geladen, aber nie den Service Worker arbeiten lassen. Genau die Luecke, durch die es durchgerutscht ist. Co-Authored-By: Claude Opus 5 <[email protected]>
198 lines
8.3 KiB
JavaScript
198 lines
8.3 KiB
JavaScript
/* =====================================================================
|
|
inhaltsrichtlinie.js — Content-Security-Policy, die sich selbst pflegt
|
|
|
|
WAS SIE VERHINDERT
|
|
|
|
Gelingt es jemandem, eigenen Text in eine Seite einzuschleusen -- über
|
|
ein Formular, eine Adresszeile, einen gespeicherten Namen --, dann
|
|
entscheidet allein diese Kopfzeile darüber, ob daraus ausgeführter
|
|
Code wird oder nur sichtbarer Text.
|
|
|
|
WARUM NICHT EINFACH 'unsafe-inline'
|
|
|
|
Das ist der übliche Weg, und er ist bequem: eine Zeile, fertig, nichts
|
|
geht kaputt. Er hebt aber genau den Schutz auf, um den es geht. Der
|
|
Browser kann nicht unterscheiden, ob ein Skript im Seitentext vom
|
|
Entwickler stammt oder von einem Angreifer -- inline ist inline. Wer
|
|
'unsafe-inline' erlaubt, hat eine Kopfzeile, die gut aussieht und im
|
|
Ernstfall nichts tut.
|
|
|
|
Diese Seite hat KEINE fremden Skriptquellen und genau einen
|
|
Inline-Block je Seite. Damit ist der saubere Weg realistisch: Von
|
|
jedem Block wird die Prüfsumme gebildet und in die Regel geschrieben.
|
|
Der Browser führt dann genau diese Blöcke aus und sonst nichts.
|
|
|
|
⚠️ WARUM DIE PRÜFSUMMEN NICHT FEST IM CODE STEHEN
|
|
|
|
Das wäre der naheliegende Weg -- und hier eine Falle mit Ansage:
|
|
Statische Dateien gehen bei diesem Projekt per "git pull" live, OHNE
|
|
Neustart (siehe DEPLOY.md). Eine fest hinterlegte Prüfsumme würde nach
|
|
der nächsten Textänderung nicht mehr zum ausgelieferten HTML passen --
|
|
und dann führt die Seite ihr eigenes Skript nicht mehr aus. Der Fehler
|
|
erschiene erst im Browser, nicht beim Deploy, und sähe aus wie ein
|
|
kaputtes JavaScript.
|
|
|
|
Deshalb liest diese Datei die HTML-Datei selbst und merkt sich das
|
|
Ergebnis, solange sich deren Änderungszeit nicht ändert. Ein "git
|
|
pull" wirkt damit sofort, ganz ohne Zutun.
|
|
|
|
WAS BEWUSST ERLAUBT BLEIBT
|
|
|
|
style-src erlaubt 'unsafe-inline'. Im Bestand stehen 587 style-Attribute
|
|
im HTML; sie alle zu ersetzen wäre eine große Umbauaktion mit echtem
|
|
Risiko für das Aussehen. Der Gewinn wäre klein: Über Stile lassen sich
|
|
Inhalte verschleiern oder überdecken, aber kein Code ausführen. Der
|
|
Unterschied zu script-src ist also nicht graduell, sondern
|
|
grundsätzlich. Bleibt als eigener Punkt auf der Liste.
|
|
===================================================================== */
|
|
|
|
import crypto from "crypto";
|
|
import { readFileSync, statSync } from "node:fs";
|
|
import { join, normalize } from "node:path";
|
|
|
|
/* Gemerkte Prüfsummen je Datei, zusammen mit der Änderungszeit, aus der
|
|
sie stammen. Ändert sich die Datei, wird neu gerechnet. */
|
|
const merker = new Map();
|
|
|
|
/* Findet alle Inline-Skriptblöcke und bildet je Block die Prüfsumme.
|
|
|
|
Wichtig: Gerechnet wird über den Inhalt EXAKT so, wie er zwischen den
|
|
Tags steht -- kein Trimmen, keine Umwandlung von Zeilenenden. Der
|
|
Browser rechnet über dieselben Bytes; schon ein entferntes Leerzeichen
|
|
ergibt eine andere Summe und damit ein blockiertes Skript. */
|
|
function summenBilden(html) {
|
|
const summen = [];
|
|
/* Nur Blöcke OHNE src. Ein <script src="..."> hat keinen Inhalt, der
|
|
zu prüfen wäre -- der ist über 'self' abgedeckt. */
|
|
const muster = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
let treffer;
|
|
while ((treffer = muster.exec(html)) !== null) {
|
|
const inhalt = treffer[1];
|
|
if (!inhalt) continue;
|
|
const summe = crypto.createHash("sha256").update(inhalt, "utf8").digest("base64");
|
|
summen.push(`'sha256-${summe}'`);
|
|
}
|
|
return summen;
|
|
}
|
|
|
|
function summenFuer(datei) {
|
|
let stand;
|
|
try {
|
|
stand = statSync(datei);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const schluessel = datei;
|
|
const gemerkt = merker.get(schluessel);
|
|
if (gemerkt && gemerkt.zeit === stand.mtimeMs) return gemerkt.summen;
|
|
|
|
let summen;
|
|
try {
|
|
summen = summenBilden(readFileSync(datei, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
merker.set(schluessel, { zeit: stand.mtimeMs, summen });
|
|
return summen;
|
|
}
|
|
|
|
/* Aus einer angefragten Adresse die HTML-Datei bestimmen, die
|
|
express.static gleich ausliefern wird. */
|
|
function dateiZuPfad(wurzel, url) {
|
|
const rein = decodeURIComponent(url.split("?")[0].split("#")[0]);
|
|
let pfad = rein.endsWith("/") ? rein + "index.html" : rein;
|
|
if (!/\.html?$/i.test(pfad)) {
|
|
/* Adressen ohne Endung liefert express.static nicht als HTML aus --
|
|
außer es ist ein Verzeichnis. Das behandelt der Fall oben. */
|
|
return null;
|
|
}
|
|
const voll = normalize(join(wurzel, pfad));
|
|
/* Sicherheitsnetz gegen "../" in der Adresse: Was außerhalb der Wurzel
|
|
landet, wird nicht angefasst. */
|
|
if (!voll.startsWith(normalize(wurzel))) return null;
|
|
return voll;
|
|
}
|
|
|
|
export function inhaltsrichtlinie(wurzel) {
|
|
return function (req, res, next) {
|
|
const datei = dateiZuPfad(wurzel, req.path === "/" ? "/index.html" : req.path);
|
|
|
|
/* ⚠️ HIER STAND EINE REGEL FÜR NICHT-SEITEN. SIE HAT DIE APP
|
|
LAHMGELEGT (26.08.2026, sofort behoben).
|
|
|
|
Der Gedanke war: "default-src 'none' für alles, was keine Seite
|
|
ist — kostet nichts und schadet nie." Der zweite Halbsatz war
|
|
falsch, und zwar an einer Stelle, die man nicht auf dem Schirm
|
|
hat:
|
|
|
|
Ein Service Worker übernimmt die Inhaltsrichtlinie, die beim
|
|
Herunterladen SEINER EIGENEN Skriptdatei gesetzt war — nicht die
|
|
der Seite, für die er arbeitet. Mit 'none' darf er nichts mehr
|
|
abrufen. Jede Anfrage scheitert, und weil er für genau diesen
|
|
Fall eine Offline-Seite anzeigt, sah es für den Benutzer aus wie
|
|
ein Netzausfall. Die Seite war online, der Server lief, alles
|
|
meldete Erfolg — und die App zeigte "Keine Verbindung".
|
|
|
|
Für Bilder, Stylesheets und Schriften bringt eine Richtlinie
|
|
ohnehin nichts: Sie steuert, was ein DOKUMENT laden darf. Ein
|
|
Bild lädt nichts nach. Es gab also nie einen Gewinn, dem der
|
|
Schaden gegenüberstand.
|
|
|
|
Deshalb: Richtlinie NUR für HTML-Dokumente. */
|
|
if (!datei) return next();
|
|
|
|
const summen = summenFuer(datei);
|
|
|
|
/* Ließ sich die Datei nicht lesen, wird KEINE strenge Regel gesetzt.
|
|
|
|
Das ist Absicht: Eine Regel ohne die passenden Prüfsummen würde
|
|
jedes Skript der Seite blockieren. Eine unbenutzbare Seite ist
|
|
schlimmer als eine ungeschützte -- und dieser Fall tritt nur ein,
|
|
wenn ohnehin etwas grundlegend nicht stimmt. */
|
|
if (!summen) return next();
|
|
|
|
const skript = ["'self'", ...summen].join(" ");
|
|
|
|
res.setHeader("Content-Security-Policy", [
|
|
"default-src 'self'",
|
|
`script-src ${skript}`,
|
|
/* Siehe Kopf der Datei: bewusste Ausnahme, eigener Punkt auf der Liste. */
|
|
"style-src 'self' 'unsafe-inline'",
|
|
/* data: für eingebettete Kleingrafiken, blob: für selbst erzeugte
|
|
Vorschauen beim Hochladen.
|
|
|
|
Die postfach-Subdomain steht hier, weil von dort ALLE selbst
|
|
gepflegten Bilder kommen: Team-Fotos, das Event des Jahres, die
|
|
Stimmen. Ohne diesen Eintrag wären auf der Startseite und auf
|
|
stimmen.html schlicht die Bilder weg -- gemeldet vom Test, der
|
|
die Seiten in einem echten Browser öffnet. Genau dafür gibt es
|
|
ihn: Der Deploy hätte Erfolg gemeldet, der Server wäre
|
|
gestartet, und der Schaden wäre erst jemandem aufgefallen, der
|
|
die Seite ansieht. */
|
|
"img-src 'self' data: blob: https://postfach.dogfather-universe.com",
|
|
"font-src 'self'",
|
|
/* Die Seite spricht mit dem eigenen Server und mit dem internen
|
|
Bereich unter postfach. -- mehr nicht. Ein eingeschleustes
|
|
Skript könnte damit keine Daten nach außen schicken. */
|
|
"connect-src 'self' https://postfach.dogfather-universe.com",
|
|
/* Kein Flash, keine Java-Applets, keine eingebetteten Objekte. */
|
|
"object-src 'none'",
|
|
/* Verhindert, dass ein eingeschleustes <base> alle relativen
|
|
Adressen der Seite auf einen fremden Server umbiegt. */
|
|
"base-uri 'self'",
|
|
/* Verhindert, dass ein Formular seine Eingaben woandershin
|
|
schickt -- der klassische Weg, ein Passwort abzugreifen, ohne
|
|
eine einzige Zeile Code auszuführen. */
|
|
"form-action 'self'",
|
|
/* Ersetzt X-Frame-Options; wirkt gegen Klickfallen. */
|
|
"frame-ancestors 'self'",
|
|
"frame-src 'none'",
|
|
"worker-src 'self'",
|
|
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */
|
|
"upgrade-insecure-requests",
|
|
].join("; "));
|
|
|
|
next();
|
|
};
|
|
}
|