Der Header Content-Security-Policy gehört zu den Dingen, die man einmal einrichtet und nie wieder öffnet. Er wird ausgeliefert, er ist syntaktisch gültig, ein automatischer Evaluator vergibt eine akzeptable Note. Genau deshalb kann eine Policy über lange Zeit funktionierenden Traffic blockieren, ohne dass es jemand bemerkt: Die üblichen Prüfwege können diese Fehlerklasse strukturell nicht erfassen.
Es folgt ein konkreter Fall auf der eigenen Website. Die Anfrage war eng gefasst: den Header durch den CSP Evaluator schicken und zwei Fehler beheben. Beide erwiesen sich als leer. Der eigentliche Defekt kam erst zum Vorschein, als die Prüfung nicht am Header, sondern am Verhalten des Browsers ansetzte.
Ein toter Allowlist-Eintrag ist kein harmloser Rest
Der Evaluator meldete zwei Befunde der Stufe error in script-src: einen Host, der für seine JSONP-Endpunkte bekannt ist, und einen Host, der Angular-Builds ausliefert. Der Umgehungsmechanismus ist in beiden Fällen derselbe. Kann ein Angreifer ein <script src> einschleusen, das auf einen erlaubten Host zeigt, lässt die Policy es durch — und danach führt ein JSONP-Callback oder Angulars Template-Engine aus, was der Angreifer braucht. Formal stehen 'self' und die Hashes; faktisch hat die Policy ein Loch.
Der Blick in den Code lieferte eine langweilige Antwort: Der Dienst, für den diese Hosts dort standen, war per Konfiguration abgeschaltet — leere Zähler-ID, Modus aus. Einer der beiden Hosts tauchte in der gesamten Codebasis nirgends auf, außer im Policy-Template selbst. Er war nie angebunden worden.
Eine Integration abzuschalten und ihre Hosts zu entfernen ist eine Änderung, nicht zwei, von denen die zweite ewig aufgeschoben wird. Umgekehrt gilt dasselbe: Kehrt die Integration zurück, müssen die Hosts in beide Direktiven zurück, in script-src und in connect-src. Sonst wird das Tag stillschweigend blockiert, während die Oberfläche weiterhin einen Schalter anbietet, der nichts einschaltet. Das gehört in den Kommentar direkt neben die Direktive — dorthin, wo die Änderung gemacht wird, nicht in ein Ticket.
Warum die übliche Prüfung das Wesentliche nicht sehen konnte
Die Website arbeitet nach dem Modell „standardmäßig abgelehnt": Ein Inline-Bootstrap setzt alle Speicherkategorien auf denied, und Drittanbieter-Tags lädt ein separates Skript von der eigenen Domain — erst nachdem die Besucherin oder der Besucher die Zustimmungsschaltfläche gedrückt hat.
Daraus folgt etwas, das man leicht übersieht: Vor der Zustimmung verlässt kein einziger Drittanbieter-Request die Seite. Jede Prüfung, die mit dem Laden der Seite endet, kann daher physisch nicht sehen, ob die Allowlist abdeckt, was die Website tatsächlich anfordert. Sie meldet null Verstöße — und das stimmt und ist zugleich irrelevant.
| Prüfweg | Was er sieht | Was er nicht sieht |
|---|---|---|
curl -I auf den Header | Syntax, die Hostliste | ob diese Liste die tatsächlichen Requests bedient |
| CSP Evaluator | umgehbare Hosts in script-src | connect-src bewertet er überhaupt nicht |
| Lighthouse, Seitenaufruf | Verstöße vor der Zustimmung — es gibt keine | alles, was nach der Zustimmung passiert |
nginx -t | dass die Datei geparst wird | nichts über das Verhalten |
Verallgemeinert reicht das über Analytics hinaus: Jede Allowlist hinter einem Consent-Gate oder einem Feature-Flag ist nur im eingeschalteten Zustand prüfbar. Ein negatives Ergebnis im Standardzustand beweist nichts — weder dass die Policy streng genug ist, noch dass sie weit genug ist.
Was die Messung mit erteilter Zustimmung fand
Die Messung im Zustand „Zustimmung erteilt" ergab auf der Artikelseite einen Verstoß, nach dem niemand gefragt hatte:
Refused to connect because it violates the
document's Content Security Policy.
https://region1.google-analytics.com/g/collect
?v=2&...&en=page_view
Der Mechanismus dahinter: Analytics sendet das Ereignis nicht an einen festen Host. Von einer europäischen Adresse aus geht es an den regionalen Endpunkt region1.google-analytics.com. In connect-src stand jedoch der exakte Host www.google-analytics.com, der den regionalen nicht abdeckt.
Genau hier lauert eine Falle mit ähnlich aussehenden Namen, auf die man leicht hereinfällt:
analytics.google.com != google-analytics.com
Die Policy enthielt https://*.analytics.google.com, was den vollständigen Eindruck erweckte, regionale Endpunkte seien abgedeckt. Es sind unterschiedliche Domains, und diese Maske deckt nichts vom Benötigten ab. Die Dokumentation schreibt für die Messung https://*.google-analytics.com vor: Sie deckt sowohl www. als auch alle regionN. ab. Die Werbe-Hosts stehen dort ebenfalls, aber unter einer eigenen Fußnote — bei abgeschalteten Werbekategorien gibt es keinen Grund, sie aufzunehmen.
Die Korrektur lief auf ein einziges Token hinaus:
- connect-src 'self' https://www.google-analytics.com ...
+ connect-src 'self' https://*.google-analytics.com ...
Zwei Einschränkungen sind für einen ehrlichen Schluss wichtig. Erstens: img-src erlaubt in dieser Policy beliebige https-Bilder, der Pixel-Transportweg war also offen — trotzdem war in der Messung vor der Korrektur der einzige Drittanbieter-Request das Tag selbst, kein erfolgreicher Aufruf an Analytics. Das Ereignis ging vollständig verloren, statt einen degradierten Weg zu nehmen. Zweitens: Der enge Host stand seit dem allerersten Commit in der Konfiguration, 73 Tage lang. Doch die Konfigurationshistorie datiert die Konfiguration, nicht den Umfang des Verlusts: Wie viel des Traffics europäisch war, folgt aus diesen Daten nicht, und eine Zahl zu behaupten wäre erfunden.
Die Methode: ein Browser, ein echter Klick, das Verstoßprotokoll
Die Prüfung läuft darauf hinaus, dieselbe Kette auszuführen, die auch Besucher durchlaufen:
- Einen Headless-Browser mit Debugging-Port starten.
- Die Seite öffnen und laden lassen.
- Die echte Zustimmungsschaltfläche klicken, statt die Zustimmung programmatisch zu setzen: Ab da läuft der eigene Code der Website, und genau diese Kette muss die Allowlist abdecken.
- Drittanbieter-Antworten mit Statuscodes sammeln, blockierte Requests mit Blockierungsgrund und Protokolleinträge zu Policy-Verstößen.
- Vor jeder URL den Speicher leeren — sonst erscheint das Banner kein zweites Mal, und der Klick wird still zum No-op.
Verschiedene Seitenvorlagen abdecken — Artikel, Rubrik, 404-Seite: Dahinter steht dieselbe Webserver-Konfiguration, aber unterschiedliches Markup. Grün sieht so aus: „Tag → 200, Ereignis → 204, Verstöße 0".
Die Methode hat ihre eigene Falle, und sie hätte beinahe einen falschen Schluss gekostet. Das Ereignis geht nicht sofort raus: Bei fünf Sekunden Wartezeit fehlt es auf den meisten Seiten. Das ist ein Messartefakt, kein Defekt — das brauchbare Fenster liegt bei rund fünfzehn Sekunden. Dasselbe Artefakt verdeckt auch echte Verstöße: Im Lauf vor der Korrektur wurde die Ablehnung nur auf einer von drei Seiten sichtbar, gerade weil der Request auf den anderen das Fenster noch nicht verlassen hatte.
Eine eigene Lehre, die nichts mit CSP zu tun hat: Eine Anforderung ohne Werkzeug wird nicht erfüllt. „Im Zustand mit erteilter Zustimmung prüfen" stand schon vorher in der internen Dokumentation — aber es gab kein Skript, also gab es auch keine Prüfung. Die Lücke schließt keine Regel, sondern ein ausführbares Skript, dessen Fehlerzweige tatsächlich durchlaufen wurden. Sonst ruht der versprochene Exit-Code auf Code, den nie jemand ausgeführt hat. Nach demselben Prinzip haben Gates in der Pipeline erst dann Bedeutung, wenn man sie hat scheitern sehen.
Zwei Grenzen, die man vorher kennen sollte
nginx scheitert an der Tokenlänge, nicht an der Headergröße. Hasht man jeden Inline-Block, wächst die Hash-Liste linear mit der Seitenzahl, bis sie an eine unscheinbare Grenze stößt: Der Konfigurationsparser begrenzt jedes einzelne Token, Zeichenketten in Anführungszeichen eingeschlossen, auf etwa 4 KB — nicht 32 KB. In der Praxis scheitert nginx -t mit too long parameter. Der Reflex, nach large_client_header_buffers zu greifen, hilft nicht: Diese Einstellung betrifft Request-Header, während der Fehler beim Parsen der Konfiguration auftritt, vor jedem Traffic.
Die Abhilfe: nur ausführbare Inline-Skripte hashen. <script type="application/ld+json">-Blöcke sind nicht ausgeführte Daten, script-src gilt für sie nicht (geprüft auf Chrome 150). Wird ein einziger gemeinsamer Bootstrap gehasht, bleibt die Hash-Menge bei eins und wächst nicht mit dem Inhalt.
strict-dynamic hat einen Preis, den die Empfehlungen meist verschweigen. Der Evaluator empfiehlt es statt einer Hostaufzählung, und zum dynamischen Laden von Tags passt es gut. Doch unter strict-dynamic wird 'self' ignoriert — und das ist nicht nur eine Verschärfung. Es fällt die Absicherung für das Zeitfenster zwischen dem Ausrollen des Markups und der Aktualisierung der Hashes weg: Ein veralteter Bootstrap-Hash bedeutet, dass überhaupt nichts ausgeführt wird, auch das Zustimmungsbanner nicht. Zudem ändert der Umstieg die Templates, erfordert also ein erneutes Deployment des gesamten Seitenbestands. Die Entscheidung hängt davon ab, was nach dem Aufräumen in der Policy übrig bleibt: Bleibt auf der Stufe error nichts, ist der Preis nicht gerechtfertigt.
Was mitzunehmen ist
CSP ist eine Policy, und für sie gilt dasselbe wie für jede Policy as Code: Sie beschreibt einen Zustand, der der Realität entsprechen soll, und weicht von ihr stillschweigend ab. Drei Dinge sind sofort erledigt billiger:
- die laufende Konfiguration vor der Änderung ziehen — der Generator legt womöglich kein eigenes Backup an, und
nginx -tbeweist nur, dass die Datei geparst wird; - im Zielzustand messen — mit erteilter Zustimmung, mit gesetztem Flag — und über verschiedene Seitenvorlagen hinweg;
- die Hostliste gegen die Dokumentation des Anbieters abgleichen statt aus dem Gedächtnis: regionale Endpunkte und ähnlich aussehende Domains sind der häufigste Grund dafür, dass eine Policy korrekt aussieht und nicht funktioniert.