Inhaltsrichtlinie (CSP): mit Pruefsummen statt mit unsafe-inline

Fuenf Schutz-Kopfzeilen waren gesetzt, die wichtigste fehlte. Sie
entscheidet als einzige darueber, ob eingeschleuster Text zu
ausgefuehrtem Code wird oder sichtbarer Text bleibt.

WARUM NICHT DER BEQUEME WEG

Ueblich waere script-src 'self' 'unsafe-inline'. Eine Zeile, nichts geht
kaputt -- und der Schutz ist weg: Der Browser kann nicht unterscheiden,
ob ein Skript im Seitentext vom Entwickler stammt oder von einem
Angreifer. Das Ergebnis ist eine Kopfzeile, die gut aussieht und im
Ernstfall nichts tut.

Der Bestand liess den sauberen Weg zu: keine fremden Skriptquellen,
keine externen Schriften, ein Inline-Block je Seite. Von jedem Block
wird die Pruefsumme gebildet.

DIE PRUEFSUMMEN STEHEN BEWUSST NICHT IM CODE

Das waere hier eine Falle mit Ansage: Statische Dateien gehen per "git
pull" live, OHNE Neustart. Eine fest hinterlegte Summe waere nach der
naechsten Textaenderung falsch -- und die Seite wuerde ihr eigenes
Skript nicht mehr ausfuehren. Sichtbar erst im Browser des Besuchers,
nicht beim Deploy, und aussehend wie kaputtes JavaScript.

Deshalb liest die Middleware die Datei selbst und merkt sich das
Ergebnis, solange die Aenderungszeit gleich bleibt. Ein Test aendert
index.html im laufenden Betrieb und prueft, dass die Summe nachzieht
und die Seite weiterlaeuft.

WAS DER TEST GEFUNDEN HAT

Die erste Fassung haette die Startseite und stimmen.html beschaedigt:
Team-Fotos, Event des Jahres und die Stimmen kommen von der
postfach-Subdomain, img-src erlaubte nur 'self'. Der Deploy haette
Erfolg gemeldet, der Server waere gestartet -- und die Bilder waeren
weg gewesen. Gefunden, weil der Test alle 48 Seiten in einem echten
Browser oeffnet und mitschreibt, was blockiert wird.

Ein zweiter Fehlschlag lag am Test selbst: Er verlangte eine Pruefsumme
auf jeder Seite, auch auf denen ohne Inline-Block. Ein Test, der
Unmoegliches fordert, wird frueher oder spaeter abgeschaltet -- er
unterscheidet jetzt nach dem tatsaechlichen Inhalt der Datei.

DREI onclick-ATTRIBUTE ENTFERNT

Sie haetten 'unsafe-inline' erzwungen. Zweimal ein "Coming soon"-Knopf,
dessen onclick nur Klicks abfing -- ein <a> ohne href tut das von
selbst, ganz ohne Skript. Einmal ein Schliessen-Knopf im
Verwaltungsbereich, jetzt mit angehaengtem Zuhoerer.

GEGENPROBE

Ohne sie waere der Rest wertlos: Eine Richtlinie, die alles erlaubt,
blockiert auch nichts und besteht jede Pruefung. Der Test schleust
deshalb echten Code ein -- ein Inline-Skript und eines von fremder
Adresse -- und beide muessen scheitern.

style-src behaelt 'unsafe-inline': 587 style-Attribute im Bestand, deren
Umbau ein echtes Risiko fuers Aussehen waere, bei kleinem Gewinn. Ueber
Stile laesst sich verschleiern und ueberdecken, aber kein Code
ausfuehren. Bleibt als eigener Punkt auf der Liste.

15 Pruefungen gruen, 48 Seiten sauber, Portfolio-Suite unveraendert 35.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-26 14:05:42 +02:00
co-authored by Claude Opus 5
parent 094a69732b
commit 03a533f143
7 changed files with 392 additions and 4 deletions
+12
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js";
import { webdesignGate } from "./webdesign-gate.js";
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -34,6 +35,17 @@ app.use((req, res, next) => {
next();
});
/* Inhaltsrichtlinie (26.08.2026). Steht bewusst HIER -- vor jeder
Schranke und vor express.static: Auch die Zugangswand und die
404-Seite sollen sie bekommen, denn gerade dort werden Eingaben
entgegengenommen.
Warum die Pruefsummen nicht fest im Code stehen, ist im Kopf von
inhaltsrichtlinie.js begruendet -- kurz: Statische Dateien gehen hier
ohne Neustart live, feste Summen waeren nach der naechsten
Textaenderung falsch und wuerden die Seite lahmlegen. */
app.use(inhaltsrichtlinie(SITE_DIR));
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use(gateMiddleware);
+181
View File
@@ -0,0 +1,181 @@
/* =====================================================================
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);
/* Für alles, was keine Seite ist (Bilder, CSS, Schriften, API),
genügt eine knappe Regel. Sie kostet nichts und schadet nie. */
if (!datei) {
res.setHeader("Content-Security-Policy",
"default-src 'none'; frame-ancestors 'self'; base-uri 'none'");
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();
};
}