Files
dogfather-universe/pruef-inhaltsrichtlinie.mjs
T
DogFatherGitandClaude Opus 5 03a533f143 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]>
2026-08-26 14:05:42 +02:00

181 lines
7.8 KiB
JavaScript

/* =====================================================================
Prueft die Inhaltsrichtlinie (Content-Security-Policy).
Bei dieser Kopfzeile ist der gefaehrliche Fehler NICHT, dass sie zu
schwach ist -- sondern dass sie zu streng ist und die Seite lahmlegt.
Und zwar erst im Browser des Besuchers: Der Deploy meldet Erfolg, der
Server startet, die Seite laedt, und dann tut kein einziger Knopf mehr
etwas. Es sieht aus wie kaputtes JavaScript und hat mit JavaScript
nichts zu tun.
Deshalb wird hier JEDE Seite in einem echten Browser geoeffnet und
mitgeschrieben, was der Browser blockiert. Eine einzige blockierte
Meldung laesst den Test durchfallen.
Der zweite Teil ist die Gegenprobe: Eine Richtlinie, die alles
erlaubt, wuerde den ersten Teil muehelos bestehen. Deshalb wird
ausdruecklich geprueft, dass sie das Gefaehrliche auch WIRKLICH
verbietet -- mit eingeschleustem Code, der scheitern muss.
===================================================================== */
import { chromium } from "playwright";
import express from "./server/node_modules/express/index.js";
import { inhaltsrichtlinie } from "./server/inhaltsrichtlinie.js";
import fs from "fs";
import path from "path";
const WURZEL = process.cwd();
let ok = 0, fehl = 0;
const pruefe = (name, gut, info) => {
if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); }
};
const app = express();
app.use(inhaltsrichtlinie(WURZEL));
app.use(express.static(WURZEL, { index: "index.html" }));
const server = app.listen(4408);
await new Promise((f) => server.on("listening", f));
const browser = await chromium.launch();
/* Alle Seiten, die es gibt -- nicht nur eine Auswahl. Gerade die selten
besuchten faellt sonst niemandem auf. */
const seitenListe = [
...fs.readdirSync(WURZEL).filter((f) => f.endsWith(".html")).map((f) => "/" + f),
...fs.readdirSync(path.join(WURZEL, "webdesign"))
.filter((f) => f.endsWith(".html")).map((f) => "/webdesign/" + f),
];
console.log(`1. BLOCKIERT DIE RICHTLINIE ETWAS? (${seitenListe.length} Seiten)`);
const betroffen = [];
for (const pfad of seitenListe) {
const seite = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const verstoesse = [];
seite.on("console", (m) => {
const t = m.text();
if (/Content Security Policy|Refused to (execute|load|apply)/i.test(t)) {
verstoesse.push(t.slice(0, 150));
}
});
await seite.goto("http://127.0.0.1:4408" + pfad, { waitUntil: "domcontentloaded" })
.catch(() => {});
await seite.waitForTimeout(700);
if (verstoesse.length) betroffen.push({ pfad, verstoesse });
await seite.close();
}
pruefe("keine Seite meldet einen Verstoss", betroffen.length === 0,
betroffen.length
? betroffen.slice(0, 3).map((b) => b.pfad + ": " + b.verstoesse[0]).join(" | ")
: seitenListe.length + " Seiten sauber");
if (betroffen.length) {
console.log("\n Betroffene Seiten im Einzelnen:");
for (const b of betroffen) {
console.log(" " + b.pfad);
for (const v of b.verstoesse.slice(0, 2)) console.log(" " + v);
}
}
console.log("\n2. STEHT UEBERALL EINE RICHTLINIE?");
/* Eine Pruefsumme kann nur dort stehen, wo es auch einen Inline-Block
gibt. Zuerst hat dieser Test sie ueberall verlangt und bei
portfolio.html und 404.html angeschlagen -- die haben schlicht keinen.
Ein Test, der Unmoegliches fordert, wird frueher oder spaeter
abgeschaltet; deshalb wird jetzt nach dem tatsaechlichen Inhalt der
Datei unterschieden. */
for (const pfad of ["/index.html", "/webdesign/portfolio.html", "/404.html",
"/webdesign/verwaltung.html"]) {
const a = await fetch("http://127.0.0.1:4408" + pfad);
const r = a.headers.get("content-security-policy") || "";
const quelle = fs.readFileSync(path.join(WURZEL, pfad), "utf8");
const hatInline = /<script(?![^>]*\ssrc=)[^>]*>[\s\S]*?<\/script>/i.test(quelle);
pruefe(` ${pfad}: Richtlinie gesetzt`,
r.includes("script-src 'self'") && r.includes("object-src 'none'"),
r ? r.slice(0, 46) + "…" : "FEHLT");
pruefe(` ${pfad}: ${hatInline ? "Pruefsumme vorhanden" : "keine noetig (kein Inline-Block)"}`,
hatInline ? r.includes("'sha256-") : !r.includes("'sha256-"));
}
/* Der Kniff, auf den es bei diesem Aufbau ankommt: Aendert sich die
HTML-Datei, muss die Pruefsumme SOFORT nachziehen. Sonst blockiert die
Richtlinie nach dem naechsten "git pull" das Skript der eigenen Seite
-- und zwar erst im Browser des Besuchers, nicht beim Deploy. */
console.log("\n2b. ZIEHT DIE PRUEFSUMME BEI EINER AENDERUNG NACH?");
const pruefDatei = path.join(WURZEL, "index.html");
const vorher = (await fetch("http://127.0.0.1:4408/index.html"))
.headers.get("content-security-policy");
const inhaltVorher = fs.readFileSync(pruefDatei, "utf8");
try {
/* Eine Zeile im Inline-Block aendern -- so, wie es eine echte
Textaenderung taete. */
fs.writeFileSync(pruefDatei,
inhaltVorher.replace("<script>", "<script>\n/* Pruefzeile */"), "utf8");
const nachher = (await fetch("http://127.0.0.1:4408/index.html"))
.headers.get("content-security-policy");
pruefe(" nach der Aenderung eine ANDERE Pruefsumme",
vorher !== nachher && (nachher || "").includes("'sha256-"),
vorher === nachher ? "unveraendert — die Seite waere jetzt kaputt" : "nachgezogen");
/* Und die neue Summe muss zum neuen Inhalt passen, nicht nur anders
sein. Das prueft ein Browser am ehrlichsten. */
const s2 = await browser.newPage();
const v2 = [];
s2.on("console", (m) => { if (/Content Security Policy|Refused to/i.test(m.text())) v2.push(m.text()); });
await s2.goto("http://127.0.0.1:4408/index.html", { waitUntil: "domcontentloaded" });
await s2.waitForTimeout(600);
pruefe(" die geaenderte Seite laeuft weiterhin", v2.length === 0, v2[0]?.slice(0, 80) || "sauber");
await s2.close();
} finally {
fs.writeFileSync(pruefDatei, inhaltVorher, "utf8");
}
const a2 = await fetch("http://127.0.0.1:4408/assets/css/main.css");
pruefe(" auch fuer Nicht-Seiten (CSS)",
(a2.headers.get("content-security-policy") || "").includes("default-src 'none'"));
console.log("\n3. GEGENPROBE: VERBIETET SIE AUCH WIRKLICH ETWAS?");
/* Ohne diesen Abschnitt waere Teil 1 wertlos: Eine Richtlinie, die
alles erlaubt, blockiert auch nichts und bestuende jede Pruefung. */
const wache = await browser.newPage();
const geblockt = [];
wache.on("console", (m) => {
if (/Content Security Policy|Refused to/i.test(m.text())) geblockt.push(m.text().slice(0, 90));
});
await wache.goto("http://127.0.0.1:4408/index.html", { waitUntil: "domcontentloaded" });
await wache.waitForTimeout(500);
/* Genau der Angriff, gegen den die Richtlinie gedacht ist: ein Skript,
das in den Seitentext geraet. Es MUSS scheitern. */
const eingeschleust = await wache.evaluate(() => {
window.__boesartig = false;
const s = document.createElement("script");
s.textContent = "window.__boesartig = true;";
document.body.appendChild(s);
return window.__boesartig;
});
pruefe("eingeschleustes Inline-Skript wird NICHT ausgefuehrt", eingeschleust === false,
eingeschleust ? "es lief durch — die Richtlinie wirkt nicht!" : "blockiert");
const fremd = await wache.evaluate(async () => {
const s = document.createElement("script");
s.src = "https://example.com/boese.js";
const geladen = new Promise((f) => { s.onload = () => f("geladen"); s.onerror = () => f("blockiert"); });
document.body.appendChild(s);
return await geladen;
});
pruefe("Skript von fremder Adresse wird NICHT geladen", fremd === "blockiert", fremd);
pruefe("der Browser hat die Blockade auch gemeldet", geblockt.length > 0,
geblockt[0] || "keine Meldung");
await browser.close();
server.close();
console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
process.exit(fehl ? 1 : 0);