Files
DogFatherGit 5d89d108f9 Die Reaction: zusammen schauen, live, mit Kamera und Chat
Filipes Kurznotiz vom 28.09.2026, von links nach rechts:

    Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/
    Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal
    -> Ende

DREI STAENDE, NICHT SIEBEN

„Wartebereich" und „Countdown" sind keine eigenen Zustaende, sondern
das, was „Vorbereitung" auf dem Bildschirm TUT. Drei Staende, die
sich gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
vier ueberlappen, sind es nicht.

DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER

Naheliegend waere: Der Host spielt ab, alle sehen seinen Bildschirm.
Das waere aus zwei Gruenden falsch. Rechtlich ist ein
weitergesendetes YouTube-Video eine oeffentliche Wiedergabe -- genau
die Sache, fuer die Kanaele gesperrt werden. Und technisch kostet es
Bandbreite und Qualitaet.

Jeder Zuschauer laedt das Video deshalb SELBST. Uebertragen wird nur
der Spielstand: Kennung, laeuft/pausiert, Sekunde. Das sind ein paar
Byte, jeder sieht es in voller Qualitaet, und alle sind auf derselben
Sekunde. Nachgefuehrt wird erst ab anderthalb Sekunden Abweichung --
ein Player, dem man jede Sekunde eine neue Position gibt, ruckelt
sichtbar.

DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH

Ueber denselben Weg wie die Anrufe im Haus (seit 18.09.), nur mit
mehr Empfaengern. Das hat eine Grenze, und sie ist gerechnet, nicht
geraten: Bei 360p und rund 350 kbit/s sind zwoelf Zuschauer etwa
4 Mbit/s Upload beim Host. Darueber schaltet die Sendung von selbst
auf Ton um -- wer keine Kamera mehr bekommt, hoert alles, sieht das
Video und kann schreiben. Ehrlicher als eine Verbindung, die stockt,
und sichtbar im Regiepult.

Heute sind es elf Menschen im ganzen Haus (gemessen: 1 admin, 1 hand,
1 linke, 4 modi, 4 gast). Die Grenze ist weit weg -- sie steht
trotzdem drin, weil sie sonst erst auffaellt, wenn es zu spaet ist.

DIE SEITE IST ANDERS GEBAUT ALS JEDE ANDERE IM HAUS

Ueberall sonst: Kacheln, Karten, Listen -- man liest, entscheidet,
geht wieder. Hier sitzt man. Eine Stunde, mit anderen, auf EINE
Sache schauend. Deshalb kein Raster, sondern ein SAAL: grosse Flaeche
fuer das Video, Kamerabilder als schwebende Fenster darueber, der
Chat als Schiene daneben. Die Seite scrollt nicht -- ein Video, das
beim Tippen im Chat nach oben rutscht, ist der schnellste Weg, dass
jemand aufhoert zu schreiben.

Fuer den Host ein REGIEPULT: vier senkrechte Regler nebeneinander wie
an einem Mischpult, darueber die Sendung, daneben Gaeste und
Anordnung, unten drei grosse Knoepfe. Es SCHIEBT den Saal, es deckt
ihn nicht zu.

Die Kachel traegt ihren Zustand als Farbe: grau geschlossen,
bernstein in Vorbereitung, rot auf Sendung. Keine Ton-Nummer -- der
Farbraum ist bei 46 voll, und sie braucht auch keine.

PAYPAL: EINE QUELLE

Der Knopf nimmt den Weg, der auf der Unterstuetzen-Seite hinterlegt
ist -- derselbe Eintrag, dieselbe Pflege. Ist dort nichts eingetragen
oder steht er auf unsichtbar, erscheint hier kein Knopf. Eine
geratene Adresse ist an dieser Stelle die gefaehrlichste aller
Abkuerzungen.

=======================================================================
ACHT FEHLER, DIE OHNE MESSUNG LIVE GEGANGEN WAEREN
=======================================================================

1. `data-live` WAR SCHON VERGEBEN. Die Draussen-Kachel bekommt es,
   sobald Filipe auf Twitch sendet. Meine Regel haette ihr waehrend
   jedes Streams die Farbe genommen -- genau dann, wenn sie wichtig
   ist. Heisst jetzt `data-sendung`, und pruef-reaktion haelt beides
   auseinander.

2. DIE INHALTSRICHTLINIE HAETTE YOUTUBE LAUTLOS GESPERRT. Die Datei
   warnt an genau dieser Stelle selbst davor: Am 27.08.2026 hat
   `frame-src 'none'` den Musik-Knopf stillgelegt -- der Knopf
   reagierte, das Feld ging auf, und wo die Player sein sollten,
   blieb es leer. Hier waere das Ergebnis eine schwarze Leinwand vor
   Publikum gewesen. youtube-nocookie.com fuer den Rahmen (setzt keine
   Werbekennungen), www.youtube.com fuer die Einbett-API,
   i.ytimg.com fuer die Vorschaubilder.

3. KAMERA UND MIKROFON WAREN GESPERRT. Dieselbe Falle, vor der
   index.js selbst warnt -- und die am 18.09. schon einmal zugeschlagen
   hat. Die Ausnahme ist jetzt eine benannte MENGE statt eines zweiten
   Sonderfalls, und pruef-kamera-richtlinie.mjs haelt sie GEGEN DEN
   QUELLTEXT: Welche Seite laedt ein Skript, das getUserMedia
   aufruft? Genau die muss drinstehen -- und keine andere. Eine
   Liste, die abgeleitet wird, kann nicht veralten.

4. ZWEI ANRUFE AN DIESELBE PERSON. Zwischen `await kameraHolen()` und
   dem Anlegen der Verbindung laeuft alles andere weiter; jeder Takt
   sagte wieder „den kenne ich noch nicht". Der Empfaenger antwortete
   auf beide Angebote, und die zweite Antwort traf eine Verbindung,
   die laengst stand.

5. DAS ANGEBOT GING HINAUS, BEVOR DER EMPFAENGER ZUHOEREN KONNTE.
   Gemessen:

       [spur] an [3] reaktion_signal | offen: [2,1]
       ...
       [spur] Strom auf fuer 3 Lenny

   Die Anmeldung ist ein gewoehnlicher Abruf und sofort durch, der
   Ereignisstrom eine stehende Verbindung. Der Host erfaehrt vom
   Neuankoemmling also zuverlaessig, BEVOR der zuhoeren kann.
   Die Richtung ist jetzt umgedreht: Wer bereit ist, BITTET um den
   Anruf -- er ist der Einzige, der das sicher weiss. Dazu ein
   eigener, schneller Takt (2,5 s) und eine Ruecknahme, wenn ein
   Angebot bei niemandem ankommt.

6. EIN VIDEO MIT TON STARTET NICHT VON ALLEIN. `videoWidth` war 640,
   das Bild kam also an -- und das Fenster blieb schwarz. Kein
   Fehler, keine Meldung, es passiert einfach nichts. Die Kameras
   starten jetzt stumm (stumm darf losgehen), ein Knopf schaltet den
   Ton frei, und die erste Beruehrung der Seite tut es ohnehin.

7. DIE LADE AM HANDY GING NICHT AUF. Gemessen: ein 390x775 grosser
   Saal mit 219 px Video und 556 px Leere darunter. Statt den Knopf
   zu reparieren, ist die Lade weg -- unter Kopfleiste und Video
   bleiben auf einem Telefon rund 550 px, das ist mehr Chat, als eine
   Lade je zeigen wuerde. Ein Zustand weniger ist besser als ein
   Zustand, der funktioniert.

8. `sendBeacon` KANN NUR POST. Beim Schliessen des Fensters wird ein
   gewoehnlicher Abruf abgebrochen; mein DELETE waere nie angekommen,
   und jeder haette zwei Minuten lang als anwesend gegolten.

Dazu drei Funde der Hauspruefungen, alle von mir verursacht:
17 Schriftgroessen unter der Lesbarkeitsgrenze von 11,5 px, elf
Maschinenworte ohne deutschen Satz, und ein Aufbewahrungseintrag ohne
Rechtsgrundlage.

=======================================================================

GEMESSEN

pruef-reaktion            74 Punkte, 0 Fehler (11 Abschnitte)
pruef-kamera-richtlinie   10 Punkte, 0 Fehler (neu, abgeleitet)
mess-reaktion             beide Kameras kommen an, 640 px, laufen --
                          beim Zuschauer UND beim Host. Diese Messung
                          hat einen Rueckgabewert: Alles andere kann
                          gruen sein, und trotzdem sitzt jeder vor
                          einem schwarzen Rechteck.
pruef-handy               180 (vorher 177), pruef-notizen 79,
pruef-aufbewahrung        45, pruef-meldungen 8, pruef-css-klassen 33,
pruef-struktur            35, pruef-crew-adresse 161,
pruef-haus-trennung       100, pruef-start-ansicht 160 -- alle 0 Fehler.
2026-09-28 01:52:22 +02:00

349 lines
17 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";
/* ==== 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 <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;
/* Genau die Normalisierung, die der HTML-Parser vornimmt: CR LF und
einzelnes CR werden zu LF (siehe Begründung im Kopf dieser Funktion). */
const wieImBrowser = inhalt.replace(/\r\n?/g, "\n");
const summe = crypto.createHash("sha256").update(wieImBrowser, "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;
}
/** Laeuft die Anfrage ueber eine unsichere Verbindung auf einen
* lokalen Namen? Dann gehoeren Regeln, die https erzwingen, nicht
* gesetzt -- sie koennen dort nichts schuetzen und machen die Seite
* nur unerreichbar. Siehe die Begruendung unten bei
* upgrade-insecure-requests. */
function istLokal(req) {
if (req.secure) return false;
const host = (req.get("host") || "").toLowerCase();
return /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?$/.test(host);
}
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, YOUTUBE_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} ${YOUTUBE_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} ${YOUTUBE_RAHMEN}`,
"worker-src 'self'",
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren.
NICHT AUF EINER LOKALEN ADRESSE (Befund 05.09.2026). Diese
Regel schreibt JEDE http-Anfrage auf https um -- auch die zu
127.0.0.1. Chromium und Firefox nehmen localhost davon aus,
WebKit nicht. Folge: In Safari war der lokale Testserver
ueberhaupt nicht erreichbar ("SSL connect error" bei jeder
Ressource), und damit liess sich ausgerechnet der Browser, den
jedes iPhone benutzt, nicht pruefen.
Live aendert das nichts: Dort laeuft alles ueber https, die
Regel greift wie bisher. Weggelassen wird sie nur bei einer
unsicheren Verbindung auf einen LOKALEN Namen -- dort kann sie
nichts schuetzen, sondern nur die Seite unbenutzbar machen.
Dieselbe Unterscheidung wie beim HSTS-Kopf in index.js, und aus
demselben Grund. */
...(istLokal(req) ? [] : ["upgrade-insecure-requests"]),
].join("; "));
next();
};
}