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]>
181 lines
7.8 KiB
JavaScript
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);
|