/* ===================================================================== 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"; /* ==== YOUTUBE (28.09.2026, fuer die Reaction) ====================== Die Reaction zeigt ein YouTube-Video, und jeder Zuschauer laedt es SELBST -- weitergesendet wird nur der Spielstand (siehe reaktion-tabellen.js). Dafuer braucht das Dokument drei Dinge: RAHMEN der eingebettete Player. `youtube-nocookie.com` setzt keine Werbekennungen, solange niemand abspielt; das ist der Grund, warum diese Adresse zuerst steht. SKRIPT die Einbett-API (`/iframe_api`) liegt auf www.youtube.com und laedt von dort ihren eigentlichen Teil nach. Ohne sie liesse sich der Player nicht fernsteuern -- und genau das ist der Kern: Alle sehen dieselbe Sekunde. BILDER die Vorschaubilder (i.ytimg.com), die im Wartebereich stehen, wenn der Host kein eigenes Bild hinterlegt hat. */ const YOUTUBE_RAHMEN = "https://www.youtube-nocookie.com https://www.youtube.com"; const YOUTUBE_SKRIPT = "https://www.youtube.com"; const YOUTUBE_BILDER = "https://i.ytimg.com https://i9.ytimg.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. Gerechnet wird über den Inhalt exakt so, wie er zwischen den Tags steht -- kein Trimmen. Schon ein entferntes Leerzeichen ergibt eine andere Summe und damit ein blockiertes Skript. ⚠️ MIT EINER AUSNAHME: ZEILENENDEN (gefunden 27.08.2026) Der Browser rechnet NICHT über die Bytes der Datei, sondern über den Text, wie er im Dokument ankommt. Und der HTML-Parser ersetzt beim Einlesen jedes CR LF und jedes einzelne CR durch ein LF -- das steht so in der HTML-Spezifikation ("preprocessing the input stream"). Eine Datei mit Windows-Zeilenenden ergibt also serverseitig eine andere Summe als im Browser, und die Seite führt ihr eigenes Skript nicht mehr aus. Aufgefallen ist es beim Prüfen der Startseite auf dem Windows-Rechner: Das Skript war blockiert, obwohl die Datei fehlerfrei war -- die Arbeitskopie hat CR LF (git core.autocrlf=true), das Verzeichnis auf dem Server hat LF. Live lief es deshalb, lokal nicht. Genau solche Unterschiede fressen Stunden, weil man den Fehler im eigenen Code sucht. Deshalb wird hier auf LF vereinheitlicht, bevor gerechnet wird. Das ist keine Bequemlichkeit, sondern die Summe, die der Browser wirklich bildet -- und es macht die Richtlinie unempfindlich dagegen, mit welchem Werkzeug eine Datei zuletzt gespeichert wurde. */ function summenBilden(html) { const summen = []; /* Nur Blöcke OHNE src. Ein