AUDIT (Auftrag: vollstaendiger Durchgang, Fehler direkt beheben)
Ausgangslage waren 40 Pruefungen mit 1616 Einzelpunkten, alle gruen.
Acht neue Pruefungen kamen dazu; sie haben gefunden, was die alten nicht
sehen konnten.
Der schwerste Fund: Die Zugangsschranke verglich req.path EXAKT gegen
eine Liste. Express raeumt Punkt-Segmente selbst weg, mehrfache
Schraegstriche aber nicht. Damit kam //workspace/start.html OHNE
Anmeldung mit HTTP 200, und ein Creator bekam ueber
/workspace//personen.html die Verwaltungsseite. Die DATEN waren nie
betroffen (nachgemessen: 404 bzw. 401). Behoben durch Normalisierung
UND eine Umkehr der Logik -- jetzt ist jede .html geschuetzt ausser der
Anmeldeseite, statt nur die in der Liste. Eine vergessene neue Seite
steht damit nicht mehr versehentlich offen.
Weiter behoben:
* Kaputter/abgebrochener Rumpf ergab 500 in HTML statt 400 in JSON --
die Oberflaeche ruft ueberall a.json() und lief in einen zweiten
Fehler; der Knopf hing ohne Meldung.
* POST /zustand/sichern war der einzige von 60 schreibenden Wegen
ohne Herkunftspruefung.
* workspace-sicherung.js gab interne Pfade in Fehlermeldungen nach
aussen; alle 23 anderen Module antworten neutral.
* admin_notiz war als einziges von 14 Feldern ohne <label>.
* Der aktive Filter hatte keinen sichtbaren Fokus (CSS-Spezifitaet
0,3,0 schlug 0,2,0) -- genau der Knopf, auf dem man steht.
* h1 -> h3 ohne Zwischenstufe auf zwei Seiten.
* HSTS ging auch ueber http mit (RFC 6797, 7.2 verbietet das).
* upgrade-insecure-requests galt auch auf 127.0.0.1 -- dadurch war
WebKit/Safari ueberhaupt nicht pruefbar, also der Browser, den
jedes iPhone benutzt.
* pruef-grosscheck las readdirSync(".") und pruefte aus server/
gestartet NULL oeffentliche Seiten -- meldete aber "ok".
Neue Pruefungen: struktur, schranke, haerte, alle-wege,
barrierefrei-workspace, breiten, tempo-workspace, browser.
Jede mit Gegenprobe und mit der geprueften Anzahl in der Bedingung.
Vier davon sind beim Bauen durch die eigene Gegenprobe aufgeflogen und
haetten sonst dauerhaft gruen gemeldet, ohne etwas zu messen.
APP-SYMBOLE (Wunsch: alle Apps der Domain, jede anders, ausser
safeaddress)
Zehn Apps, zehn Stile, zehn in OKLCH gerechnete Farben. Zusammen haelt
sie dasselbe Logo, dieselbe Eckenrundung und eine gemeinsame gedeckte
Farbreihe. Beim Bauen wird gemessen, ob sich das Logo vom Grund abhebt
(19 bis 58 Helligkeitsstufen).
Dabei aufgefallen: Das Kundenportal hatte kein eigenes Manifest und
trug Namen und Symbol der Webdesign-Seite. Der Workspace hatte gar
keins und war als App nicht installierbar. Beide haben jetzt eins.
Geaendert wurde AUSSCHLIESSLICH das Symbol. Ein Zwischenstand hatte
auch die Themenfarben gesetzt; das war mehr als bestellt und wurde
zurueckgenommen.
Werkzeuge: tools/logo-freistellen.mjs, tools/app-symbole.mjs,
tools/app-symbole-einbinden.mjs -- alles im Browser gerechnet, kein
Bildprogramm, keine neue Abhaengigkeit.
Gitea und Nextcloud sind bereits live und nachgeprueft.
Co-Authored-By: Claude Opus 5 <[email protected]>
330 lines
16 KiB
JavaScript
330 lines
16 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.
|
|
|
|
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].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.
|
|
|
|
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();
|
|
};
|
|
}
|