Fortsetzung des Musik-Fundes: PayPal und Google haetten an derselben Stelle still versagt, sobald Dogi seine Zugangsdaten eintraegt -- leerer Bereich statt Bezahlknopf, ohne Fehlermeldung, ohne Protokolleintrag. Vorgehen bewusst gemessen statt geraten: Erst die Angaben der Anbieter (PayPal "Best Practices", Google CSP-Abschnitt der Setup-Anleitung), dann im echten Browser mit PayPals offizieller Testkennung "client-id=test" nachgemessen und die Verstoesse ueber das Ereignis securitypolicyviolation eingesammelt. Die Messung hat zwei Dinge gefunden, die in keiner Anleitung standen: - www.sandbox.paypal.com (frame-src + connect-src). Beim Einrichten testet man mit Sandbox-Zugangsdaten; ohne diesen Eintrag haette die Kasse in genau dieser Phase nicht abgeschlossen werden koennen. - accounts.google.com/gsi/style (style-src). 'unsafe-inline' deckt das NICHT ab -- es erlaubt nur Stile im Dokument, keine nachgeladene Stilvorlage. Der Anmelde-Knopf waere unformatiert erschienen. Bewusst einzelne Adressen statt PayPals vorgeschlagener Platzhalter (*.paypal.com): Was die Messung nicht gebraucht hat, steht nicht drin. Skripte bleiben ohne 'unsafe-inline' -- die Pruefsummen-Loesung fuer die eigenen Inline-Bloecke bleibt unangetastet, und ein zusaetzliches 'unsafe-inline' waere neben Pruefsummen ohnehin wirkungslos. Neuer Test pruef-kasse.mjs (15 Pruefungen): laedt das echte PayPal-SDK, baut die Bezahlknoepfe wirklich auf, rendert den echten Google-Knopf und verlangt NULL Verstoesse. Bestandstest pruef-inhaltsrichtlinie.mjs (22) und pruef-musik.mjs (17) bleiben gruen -- inklusive der Gegenprobe, dass eingeschleuste Skripte weiterhin blockiert werden. Co-Authored-By: Claude Opus 5 <[email protected]>
279 lines
13 KiB
JavaScript
279 lines
13 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";
|
|
|
|
/* =====================================================================
|
|
FREMDE QUELLEN — hier stehen sie namentlich, nicht verstreut
|
|
|
|
Drei Dinge auf dieser Seite kommen nicht vom eigenen Server: die
|
|
Spotify-Player im Musik-Feld, die PayPal-Kasse und die
|
|
Google-Anmeldung auf abonnieren.html.
|
|
|
|
Warum sie hier zusammenstehen: Wer eine Quelle vergisst, merkt es
|
|
NICHT beim Deploy und nicht im Server-Protokoll. Der Browser wirft
|
|
das Element still weg -- genau so ist am 26.08.2026 der Musik-Knopf
|
|
ausgefallen und erst am 27.08. aufgefallen. Eine Liste an einer
|
|
Stelle, mit Begründung je Eintrag, ist die einzige Stelle, an der man
|
|
beim nächsten Mal nachsieht.
|
|
|
|
Ausgewählt wurde nach den Angaben der Anbieter (PayPal: "Best
|
|
Practices" der JS-SDK-Doku, Google: CSP-Abschnitt der Setup-Anleitung)
|
|
und danach mit pruef-kasse.mjs im echten Browser nachgemessen: Die
|
|
Kasse wird dort mit PayPals offizieller Testkennung wirklich
|
|
aufgebaut, die Anmeldung wirklich gerendert. Was dabei nicht gebraucht
|
|
wurde, steht hier auch nicht -- deshalb einzelne Adressen statt der
|
|
von PayPal vorgeschlagenen Platzhalter *.paypal.com.
|
|
===================================================================== */
|
|
|
|
/* Spotify: die zwei Player im Musik-Feld (main.js, jede Seite). */
|
|
const SPOTIFY = "https://open.spotify.com";
|
|
|
|
/* PayPal: das SDK selbst liegt auf www.paypal.com, Bilder/Schriften der
|
|
Knöpfe auf www.paypalobjects.com. Der Bezahlvorgang läuft anschließend
|
|
in einem Rahmen von www.paypal.com.
|
|
|
|
⚠️ Die Übungs-Umgebung (www.sandbox.paypal.com) steht bewusst MIT
|
|
drin, obwohl im Betrieb nur www.paypal.com gebraucht wird. Grund:
|
|
Beim Einrichten testet man zuerst mit Sandbox-Zugangsdaten -- das SDK
|
|
spricht dann ausschließlich mit der Sandbox-Adresse. Ohne diesen
|
|
Eintrag hätte Dogi beim ersten Testkauf eine Kasse gesehen, die sich
|
|
nicht abschließen lässt, ohne jeden Hinweis auf den Grund. Genau
|
|
dieser Verstoß ist bei der Messung mit PayPals Testkennung
|
|
aufgetreten -- geraten hätte man ihn nicht. */
|
|
const PAYPAL_SKRIPT = "https://www.paypal.com https://www.paypalobjects.com";
|
|
const PAYPAL_RAHMEN = "https://www.paypal.com https://www.sandbox.paypal.com";
|
|
const PAYPAL_BILDER = "https://www.paypal.com https://www.paypalobjects.com";
|
|
const PAYPAL_VERBINDUNG = "https://www.paypal.com https://www.paypalobjects.com https://www.sandbox.paypal.com";
|
|
|
|
/* Google-Anmeldung: Google nennt ausdrücklich die ELTERN-Adresse
|
|
.../gsi/ statt einzelner Endpunkte -- damit die Anmeldung nicht bricht,
|
|
wenn Google intern eine Adresse ändert. */
|
|
const GOOGLE_SKRIPT = "https://accounts.google.com/gsi/client";
|
|
const GOOGLE_RAHMEN = "https://accounts.google.com/gsi/";
|
|
const GOOGLE_VERBINDUNG = "https://accounts.google.com/gsi/";
|
|
/* Der Anmelde-Knopf holt sein Aussehen als eigene Stilvorlage. Wichtig:
|
|
'unsafe-inline' weiter unten deckt das NICHT ab -- das erlaubt nur
|
|
Stile IM Dokument, keine nachgeladene Datei von fremder Adresse. Ohne
|
|
diesen Eintrag erscheint der Knopf unfertig/unformatiert. */
|
|
const GOOGLE_STIL = "https://accounts.google.com/gsi/style";
|
|
|
|
/* 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, PAYPAL_SKRIPT, GOOGLE_SKRIPT].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' ${GOOGLE_STIL}`,
|
|
/* 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 ${PAYPAL_BILDER}`,
|
|
"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 ${PAYPAL_VERBINDUNG} ${GOOGLE_VERBINDUNG}`,
|
|
/* 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'",
|
|
/* ⚠️ HIER STAND "frame-src 'none'" — DAS HAT DEN MUSIK-KNOPF
|
|
STILLGELEGT (gemeldet 27.08.2026).
|
|
|
|
Der Knopf unten rechts klappt ein Feld mit zwei Spotify-Playern
|
|
auf (Hasidog und Van-Van, siehe main.js). Genau die sind
|
|
<iframe>s, und 'none' verbietet dem Dokument JEDE Einbettung.
|
|
Sichtbar war das denkbar undankbar: Der Knopf reagierte, das
|
|
Feld ging auf, die Überschriften standen da — nur wo die Player
|
|
sein sollten, blieb es leer. Nichts stürzte ab, nichts meldete
|
|
einen Fehler; die Begründung stand ausschließlich in der
|
|
Browser-Konsole.
|
|
|
|
Erlaubt ist deshalb genau eine Quelle: open.spotify.com. Das ist
|
|
kein Aufweichen der Richtlinie -- 'none' war schlicht zu eng für
|
|
eine Seite, die diese Player seit jeher einbettet. Was INNERHALB
|
|
des Spotify-Rahmens passiert, regelt Spotifys eigene Richtlinie,
|
|
nicht unsere; ein eingeschleustes Skript gewinnt hier also nichts
|
|
außer der Möglichkeit, ausgerechnet Spotify einzubetten.
|
|
|
|
Kommt später ein weiterer Anbieter dazu (PayPal-Kasse und
|
|
Google-Anmeldung auf abonnieren.html arbeiten ebenfalls mit
|
|
Rahmen), muss er hier NAMENTLICH ergänzt werden -- sonst
|
|
wiederholt sich exakt dieser stille Ausfall an der Kasse.
|
|
pruef-musik.mjs und pruef-kasse.mjs prüfen das im echten Browser
|
|
mit -- inklusive der Kasse, die vorher genau hier gescheitert
|
|
wäre. */
|
|
`frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`,
|
|
"worker-src 'self'",
|
|
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */
|
|
"upgrade-insecure-requests",
|
|
].join("; "));
|
|
|
|
next();
|
|
};
|
|
}
|