Files
dogfather-universe/server/inhaltsrichtlinie.js
T
DogFatherGitandClaude Opus 5 1d99da8ef8 Musik-Knopf repariert: Inhaltsrichtlinie blockierte die Spotify-Player
Gemeldet am 27.08.2026 ("unten rechts laeuft was nicht richtig mit der
Musik"). Ursache lag nicht in der Seite, sondern in der am 26.08.2026
eingefuehrten Inhaltsrichtlinie: "frame-src 'none'" verbietet dem
Dokument jede Einbettung -- und die beiden Player (Hasidog, Van-Van)
sind genau das. Der Knopf reagierte, das Feld ging auf, die Player
blieben leer. Kein Absturz, keine Fehlermeldung auf der Seite; die
Begruendung stand nur in der Browser-Konsole.

- frame-src erlaubt jetzt genau eine Quelle: https://open.spotify.com.
  Kein Sternchen, kein 'unsafe-*'. Was im Spotify-Rahmen passiert,
  regelt Spotifys eigene Richtlinie.
- Neuer Test pruef-musik.mjs (17 Pruefungen). Er startet bewusst den
  ECHTEN server/index.js statt eines Datei-Servers -- ein einfacher
  Datei-Server erzeugt die Kopfzeile gar nicht und haette den Fehler
  nie gesehen. Geprueft wird im echten Browser, ob die Rahmen wirklich
  von open.spotify.com laden (statt einer Fehlerseite), dazu Tippziel,
  Position, Schliessen per Knopf und Escape, Handy-Layout.

Beim Nachsehen aufgefallen und NOCH OFFEN: PayPal-Kasse und
Google-Anmeldung auf abonnieren.html laden ihre Skripte und Rahmen
ebenfalls von fremden Adressen. Beide sind derzeit nicht scharf
(googleClientId/paypalClientId sind null), wuerden aber mit derselben
Richtlinie an derselben Stelle still ausfallen, sobald Dogi seine
Zugangsdaten eintraegt. Wird gesondert mit ihm besprochen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:52:36 +02:00

222 lines
9.7 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";
/* 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].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'",
/* ⚠️ 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 prüft das im echten Browser mit. */
"frame-src https://open.spotify.com",
"worker-src 'self'",
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */
"upgrade-insecure-requests",
].join("; "));
next();
};
}