// Tutorial · Web · Security

Content Security Policy in nginx: CSP richtig setzen – mit PairDrop und SkySend

📅 20.09.2026 ⏱ 19 Min. Lesezeit

HSTS, nosniff, Referrer-Policy – die Grundlagen der Sicherheits-Header haben wir in Teil 2 der nginx-Serie gesetzt. Ein Header fehlt dort systematisch, weil er sich nicht „mal eben“ dazuschreiben lässt: die Content Security Policy. Sie ist der einzige Header, der nicht nur transportiert, sondern im Browser entscheidet, was eine Seite laden und ausführen darf. Dieser Artikel erklärt sie ausführlich, zeigt zwei fertige Policies für selbst gehostete Dienste (PairDrop und SkySend) und den Weg dorthin, der keine Besucher aussperrt.

Was CSP leistet – und was nicht

Eine Content Security Policy ist eine Erlaubnisliste, die der Server mit jeder Antwort verschickt. Sie legt fest, von welchen Quellen der Browser Skripte, Stile, Bilder, Schriften und Verbindungen akzeptiert. Der Nutzen entsteht nicht dadurch, dass deine Seite unsicher wäre – sondern dadurch, dass eine Einschleusung dann nicht mehr ausgenutzt werden kann.

AngriffOhne CSPMit CSP
Eingeschleustes FremdskriptLäuft im Kontext deiner Seite, liest Cookies und SitzungenWird blockiert, wenn die Quelle nicht erlaubt ist – oder wenn Nonce/Hash fehlt
Injizierter <script>-Block im HTMLFührt direkt ausWird blockiert, sobald kein 'unsafe-inline' erlaubt ist
Clickjacking (Seite in fremdem Frame)Angreifer legt unsichtbare Klickflächen darüberframe-ancestors 'none' verhindert die Einbettung
Datenabfluss über fetch/WebSocketBeliebige Ziele erreichbarconnect-src grenzt die Ziele ein
Umleitung von FormularenFormular postet an einen Fremdhostform-action 'self' verhindert das
<base>-Tag-TrickAlle relativen Pfade zeigen plötzlich auf den Angreiferbase-uri 'none' unterbindet das
Was CSP nicht kann: Wenn dein Server selbst manipulierten HTML-Code ausliefert, kann ein erlaubtes eigenes Skript weiterhin Schaden anrichten – CSP verhindert fremden Code, nicht schlechten eigenen. Sie ersetzt auch keine Verschlüsselung, kein nosniff und keine Backups. Sie ist das Sicherheitsnetz für den Fall, dass etwas durchkommt.
Warum sich der Aufwand trotzdem lohnt: Selbst gehostete Apps sind selten das Ziel gezielter Angriffe, aber häufig das Ziel automatischer Scanner. Eine strikte Policy macht aus einem kleinen Ausführungsfehler einen wirkungslosen – und sie kostet dich am Ende genau eine Zeile pro Serverblock.

Die Direktiven in der Praxis

Eine Policy ist eine Folge von direktive quelle quelle, getrennt durch Semikolon. default-src ist der Auffangwert für alles, was nicht einzeln genannt wird. In der Praxis sind es diese hier:

DirektiveSteuertWas bricht, wenn sie zu eng ist
default-src 'self'Alles, was nicht extra aufgeführt istSehr viel – deshalb möglichst gezielt überschreiben
script-srcJavaScript-Dateien und Inline-SkripteDie App startet nicht mehr: leere Seite oder eingefrorene Oberfläche
style-srcCSS-Dateien und Inline-StileLayout kaputt – typischer Fall für 'unsafe-inline'
img-srcBilder, Icons, VorschaubilderFehlende Bilder – bei Apps mit Canvas-Vorschau braucht es blob: und data:
font-srcSchriftdateienFalsche Schriftart (Fallback) oder gar keine Anzeige bei Icon-Fonts
connect-srcfetch, XHR, WebSocket, EventSourceUploads, Statusmeldungen und Live-Updates brechen ab
media-srcAudio und VideoWiedergabe startet nicht, Vorschau bleibt schwarz
worker-srcWeb- und Service-WorkerPWA-Funktionen, Offline-Modus, Upload-Worker fallen aus
manifest-srcWeb-App-ManifestInstallierbarkeit, Icon und Name der PWA fehlen
frame-srcEingebettete iframesEinbettungen (Karten, Videos) bleiben leer
object-src 'none'Plugins, Flash-Erben, Embed-ObjektePraktisch nichts – gehört immer auf 'none'
base-uri 'none'<base>-TagNichts, außer die Seite nutzt bewusst ein Basis-Tag
form-action 'self'Ziel von Formular-AbsendungenLogin- oder Suchformulare schlagen fehl, wenn sie extern posten
frame-ancestors 'none'Wer deine Seite einbetten darfEinbettung in fremde Seiten – gewollt bei Widgets
upgrade-insecure-requestsAnheben von http:// auf https://Alte http-Ressourcen laden nicht mehr – in der Regel gewünscht
Grundgerüst, das bei fast jedem Dienst funktioniert: default-src 'self'; img-src 'self' data: blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests – und dann nur das freigeben, was die App nachweislich braucht: connect-src für WebSockets, worker-src für Worker, media-src für Medien.

Die drei, die nie etwas kaputtmachen

Wenn du Zeit für nichts anderes hast, setze diese drei – sie kosten keine Funktion und schließen drei Angriffsklassen:

add_header Content-Security-Policy "object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
DirektiveVerhindertRisiko
object-src 'none'Einbetten aktiver Inhalte über Plugin-SchnittstellenKeins – moderne Apps nutzen das nicht
base-uri 'none'Umleiten aller relativen Pfade über ein injiziertes <base>Keins, solange die App kein eigenes Basis-Tag braucht
frame-ancestors 'none'Clickjacking durch EinbettungNur, wenn du selbst einbettest – dann 'self' statt 'none'
Ersatz für X-Frame-Options: frame-ancestors ist der moderne Weg und kann mehr (mehrere erlaubte Eltern, Wildcards). Du darfst beide Header parallel senden – alte Clients nehmen den einen, neue den anderen.

'unsafe-inline', Hashes, Nonces, strict-dynamic

Der häufigste Grund, warum eine Policy „nicht geht“, sind Inline-Skripte und Inline-Stile. Ohne Erlaubnis blockiert der Browser jeden <script>-Block und jedes style="…"-Attribut. Drei Wege führen daraus – mit sehr unterschiedlichem Aufwand:

WegWie es funktioniertAufwand und Grenzen
'unsafe-inline'Inline-Code generell erlaubtNull Aufwand, aber der Schutz gegen eingeschleusten Inline-Code entfällt
HashDer SHA-256-Wert jedes erlaubten Inline-Blocks steht in der PolicySehr strikt – aber jede Änderung an der App ändert den Hash und bricht die Seite
NonceEin Zufallswert pro Antwort wird in Policy und Skript-Tag gesetztStark, aber die App muss den Nonce in ihre Tags schreiben – bei Fremd-Apps oft nicht möglich
'strict-dynamic'Nur mit Nonce/Hash: erlaubte Skripte dürfen weitere laden; Host-Listen werden ignoriertDer modernste Weg – setzt Nonce oder Hash voraus

Für selbst gehostete Fremd-Apps (also der Normalfall bei PairDrop und SkySend) läuft es praktisch auf 'unsafe-inline' für Stile hinaus, während Skripte meist strikt bleiben können. Das ist ein bewusster Kompromiss: Stil-Injection ist deutlich harmloser als Skript-Injection.

Nonce per nginx ist möglich, aber ein Sonderweg. Mit $request_id kannst du einen Zufallswert erzeugen, ihn in den Header schreiben und über sub_filter in die HTML-Antwort einsetzen. Das funktioniert nur bei Inhalten, die du selbst ausliefern kannst, verlangt zusätzliche Konfiguration (Kompression aus, sub_filter_once off) und bricht, sobald die App eigene Skript-Tags dynamisch erzeugt. Für eigene Templates ein Gewinn – für fremde Apps Rate- und Fehlerquelle.

Der sichere Weg: erst Report-Only

Der einzige Weg, eine CSP einzuführen, ohne Besucher auszusperren: erst beobachten, dann durchsetzen. Dafür gibt es denselben Header mit angehängtem -Report-Only – er blockiert nichts, sondern meldet nur.

# Schritt 1: beobachten (blockiert nichts)
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;

# Schritt 2: nach der Auswertung scharf schalten
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;

Der wichtigste Trick kostet nichts: die Konsole des Browsers ist dein Report-Endpunkt. Bei jedem Verstoß erscheint dort eine Meldung mit der verletzten Direktive und der blockierten Adresse. Öffne die Seite, klicke dich durch die Funktionen – hochladen, teilen, Vorschau, PWA installieren – und lies die Ausgabe. Nach zehn Minuten weißt du mehr über die App als jede Dokumentation verrät.

Testweise ohne Endpunkt arbeiten: Report-Only ohne report-uri ist völlig legitim. Der Browser loggt trotzdem – und du vermeidest, dass deine Besucher Meldungen an irgendeinen Server schicken. Für einen ersten Durchlauf ist das der datenschutzfreundlichste Weg.

Reports sammeln ohne Drittanbieter

Wenn du Reports zentral auswerten willst, kannst du das selbst bauen. Zwei Wege:

# Variante A: Browser senden an den eigenen Endpunkt
add_header Content-Security-Policy-Report-Only "…; report-uri /csp-report" always;

# Variante B (moderner, ersetzt report-uri): Reporting-API
add_header Report-To '{"group":"csp","max_age":86400,"endpoints":[{"url":"https://pd.huuu.biz/csp-report"}]}' always;
# und in der Policy: report-to csp

Für den Endpunkt habe ich eine naheliegende Idee getestet, die nicht funktioniert: Eine reine nginx-Lösung mit return 204; und $request_body im Log. Das Ergebnis: HTTP 204 kommt an, das Log enthält aber nur einen Bindestrich – nginx liest den Body bei einem return nicht, also ist $request_body leer. Wenn du die Reports wirklich speichern willst, brauchst du ein Backend davor:

location = /csp-report {
    proxy_pass http://127.0.0.1:8787;    # kleiner Sammler, siehe Skript unten
    client_max_body_size 32k;            # Reports sind klein – Bremse gegen Missbrauch
    access_log off;
}

Der Sammler ist ein Dutzend Zeilen: Body lesen, als JSON-Zeile anhängen, mit 204 antworten. Genau in dieser Form läuft er im Test – der Browser bekommt sein 204, in der Logdatei steht der vollständige Report:

#!/usr/bin/env node
// csp-collector.mjs  -  sammelt CSP-Reports in eine JSONL-Datei
import http from "node:http";
import fs from "node:fs";

const PORT = Number(process.env.PORT || 8787);
const LOG  = process.env.LOG || "/var/log/csp-reports.jsonl";

http.createServer((req, res) => {
  if (req.method !== "POST" || !req.url.startsWith("/csp-report")) {
    res.writeHead(404); return res.end();
  }
  let body = "";
  req.on("data", (c) => { body += c; if (body.length > 64 * 1024) req.destroy(); });
  req.on("end", () => {
    fs.appendFileSync(LOG, JSON.stringify({ zeit: new Date().toISOString(), ip: req.socket.remoteAddress, rohdaten: body.slice(0, 8000) }) + "\n");
    res.writeHead(204); res.end();          // 204 genuegt dem Browser
  });
}).listen(PORT, "127.0.0.1", () => console.log("Collector auf 127.0.0.1:" + PORT));

Mit socat, einem systemd-Unit und einer Logrotation läuft das dauerhaft. Wer es „richtig“ will, nimmt einen selbst gehosteten Fehlersammler (etwa GlitchTip oder Sentry) und zeigt dorthin – dann hast du Gruppierung und Auswertung, aber wieder einen Dienst mehr zu warten.

Reports sind Nutzerdaten. Sie enthalten die aufgerufene Seite, die blockierte Adresse und je nach Browser weitere Details. Wenn du sie speicherst, gehören sie in deine Datenschutzerklärung – und die Logdatei braucht eine Aufbewahrungsgrenze (Rotation). Genau deshalb ist die Konsole für Hobby-Setups oft die bessere Wahl.

Praxis 1: PairDrop absichern

PairDrop ist eine WebRTC-Anwendung: Zwei Geräte finden sich über einen Vermittlungsserver und übertragen Daten anschließend direkt miteinander. Für die Policy heißt das:

Was die App brauchtDirektive
Signalisierung per WebSocket auf derselben Domainconnect-src 'self'
Vorschaubilder, die im Browser entstehen (Canvas, Blob-Links)img-src 'self' data: blob:
Vorschau von Audio/Video vor dem Sendenmedia-src 'self' blob:
Als installierbare Web-App mit Offline-Fähigkeitworker-src 'self' blob: und manifest-src 'self'
Dynamisch aufgebaute Oberfläche (Stile)style-src 'self' 'unsafe-inline'
Einbettung in fremde Seitenframe-ancestors 'none' – bei einem Transferdienst unerwünscht

Daraus entsteht die Policy für pd.huuu.biz beziehungsweise deine eigene Instanz:

# Einmalig im http-Kontext definieren (z. B. conf.d/websocket-upgrade.conf):
map $http_upgrade $connection_upgrade { default upgrade; '' close; }

server {
    listen 443 ssl;
    http2 on;
    server_name pd.meine.domain;

    ssl_certificate     /etc/ssl/private/pd.meine.domain_ecc/fullchain.cer;
    ssl_certificate_key /etc/ssl/private/pd.meine.domain_ecc/pd.meine.domain.key;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;      # WebSocket-Signalisierung
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://127.0.0.1:50513;
    }
}
Der Irrtum bei WebRTC: Man sieht in fertigen Konfigurationen oft connect-src 'self' stun: stun.example.org. Das ist wirkungslos: CSP kann die Peer-to-Peer-Verbindungen von WebRTC nicht einschränken. Erlaubt werden muss nur die Signalisierung – und die läuft bei PairDrop über WebSocket auf der eigenen Domain, also 'self'. Wer eigene STUN/TURN-Server nutzt, trägt sie in rtc_config.json ein (siehe PairDrop-Artikel), nicht in der CSP.
Vorgehen in drei Runden: ① Report-Only mit dem Grundgerüst setzen, Seite laden, Datei senden, Vorschau ansehen. ② Jede Meldung aus der Konsole abarbeiten – meist fehlen blob: oder worker-src. ③ Erst dann -Report-Only aus dem Header entfernen und einmal nginx -t && systemctl reload nginx.

Praxis 2: SkySend absichern

SkySend ist der zweite gute Testfall, weil es anders funktioniert: Die Dateien werden im Browser verschlüsselt, der Schlüssel steckt im URL-Fragment, und die Uploads laufen laut Konfiguration über FILE_UPLOAD_WS per WebSocket. Genau diese Punkte muss die Policy abbilden.

Was die App brauchtDirektiveWarum
Uploads per WebSocketconnect-src 'self'Gleiche Domain, gleiche Herkunft – der Upload-Kanal läuft über das Fragment-freie Pfadsystem
Verschlüsselung im Browserscript-src 'self'Kein Fremd-CDN, keine Inline-Skripte nötig
Fortschrittsanzeige und Ausschnitteimg-src 'self' data: blob:Vorschauen entstehen lokal als Blob
Große Uploads über Workerworker-src 'self' blob:Wenn die App in Worker auslagert, sonst bricht der Upload ab
Installierbare Web-Appmanifest-src 'self'Icon und Name der PWA
Einbettungframe-ancestors 'none'Ein Teilen-Dienst gehört nicht in fremde Frames

Die fertige Policy für sky.huuu.biz beziehungsweise deine Instanz:

    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
BesonderheitWas du tun solltest
'wasm-unsafe-eval'Nur nötig, wenn die App WebAssembly für die Verschlüsselung nutzt. Im Report-Only-Lauf siehst du eine Meldung, falls es fehlt („WebAssembly compilation blocked“) – dann Token aufnehmen, sonst weglassen.
Referrer-PolicyBleibt auf no-referrer wie im SkySend-Artikel: Der Schlüssel im URL-Fragment darf nirgends landen.
Sehr große Uploadsclient_max_body_size 0; beibehalten – das Limit prüft die App selbst. Die CSP ist davon unberührt.
Download-LinksKein CSP-Thema: Downloads sind Navigation, keine Ressource. form-action und img-src reichen.
Prüfe den Upload aktiv, nicht nur den Seitenaufbau. Bei WebSocket-Uploads fällt ein fehlender connect-src-Eintrag erst beim tatsächlichen Hochladen auf – die Seite sieht vorher vollständig in Ordnung aus. Lade also im Report-Only-Lauf eine große Datei hoch und beobachte die Konsole.

nginx-Fallen beim Setzen der Header

FalleVerhaltenLösung
add_header erbt nichtSobald in einer location ein eigener add_header steht, gelten die Header der Server-Ebene dort nicht mehrSicherheits-Header in Snippets auslagern und in jede Ebene per include einbinden
Kein alwaysBei 4xx/5xx-Antworten fehlen die Header – genau dort sind sie interessantImmer mit always setzen
Zwei CSP-HeaderDer Browser setzt beide durch (Schnittmenge) – die strengere gewinntGenau eine Policy pro Antwort, auch über Snippets sicherstellen
Zeilenumbruch im WertGetestet: nginx akzeptiert einen mehrzeiligen Wert und schickt ihn als eine Zeile mit LeerzeichenLesbar ist es – üblich bleibt die einzeilige Schreibweise, weil sie beim Kopieren keine Überraschungen macht
Leerzeichen vor dem SemikolonKein Problem – die Policy bleibt gültigKosmetik, kein Grund zur Sorge
# /etc/nginx/snippets/security-headers.conf  -  gemeinsame Basis
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# /etc/nginx/snippets/csp-pairdrop.conf      -  app-spezifisch
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; …" always;

# im Serverblock
include snippets/security-headers.conf;
include snippets/csp-pairdrop.conf;

Mehrere Dienste, eine Domain

Sobald zwei Apps dieselbe Domain teilen, brauchst du die CSP pro location – und läufst in die Vererbungsregel. Sauber geht es so:

AufbauCSPBewertung
Eine Subdomain pro App (Empfehlung)Ein eigener Serverblock mit eigener PolicyKlar, wartbar, keine Vererbungsfallen – so arbeiten auch unsere Demo-Instanzen
Mehrere Apps unter einer Domain per PfadPro location mit eigenem includeMöglich, aber jeder Pfad braucht die vollständige Header-Liste; schnell unübersichtlich
Eine Policy für allesServerweitNur sinnvoll, wenn alle Apps dieselben Bedürfnisse haben – praktisch selten
Die wichtigste Architekturentscheidung steckt hier: Eine CSP ist eine Aussage über eine Anwendung. Wer zehn Dienste unter einer Domain bündelt, muss die strengste Policy von allen wählen – und verliert damit genau die Schärfe, die CSP wertvoll macht. Eine Subdomain pro Dienst ist nicht nur ordentlicher, sondern auch sicherer.

Testen, ohne Besucher zu verlieren

# 1) Ist der Header ueberhaupt gesetzt?
curl -sI https://pd.huuu.biz/ | grep -i 'content-security'

# 2) Scharf oder nur im Testmodus?
curl -sI https://pd.huuu.biz/ | grep -io 'content-security-policy\(-report-only\)\?'

# 3) Konfiguration vor dem Reload pruefen
sudo nginx -t && sudo systemctl reload nginx
PrüfungWieAussage
GrundzustandSeite laden, Konsole öffnen, Fehlerliste leer?Zeigt sofort, ob die Policy zu streng ist
Alle Funktionen durchklickenHochladen, teilen, Vorschau, Einstellungen, PWA installierenDer wichtigste Test überhaupt – nur hier fallen connect-src und worker-src auf
Angriff simulierenIn einer Testseite ein Skript von einem Fremdhost einbindenWird es blockiert, wirkt script-src – inklusive Meldung in der Konsole
Einbettung testenDie Seite in einem iframe auf einer anderen Domain ladenBleibt sie leer, greift frame-ancestors
Report-Only-PhaseMehrere Tage mit echten Nutzern laufen lassenDeckt Wege auf, die du selbst nie klickst
Testmatrix der Browser: Prüfe mindestens einen Chromium-Browser und Firefox. Safari ignoriert einige Tokens (etwa 'wasm-unsafe-eval' in älteren Versionen) und meldet anders – wenn eine Funktion nur in einem Browser bricht, ist das die Erklärung.

Wenn es schiefgeht

SymptomUrsacheVorgehen
Komplett weiße SeiteSkripte blockiertKonsole lesen, script-src prüfen – im Zweifel kurzfristig Report-Only zurück
Layout zerschossen, Funktionen gehenInline-Stile blockiertstyle-src 'self' 'unsafe-inline' ergänzen
Bilder oder Vorschauen fehlenblob: bzw. data: fehlt in img-srcBeide Quellen aufnehmen – sie sind bei lokalen Vorschauen unvermeidlich
Upload bricht ab, Seite wirkt okayWebSocket von connect-src blockiertconnect-src 'self' setzen, Upload erneut testen
„Refused to connect“ in der KonsoleFremd-API wird angesprochenEntweder freigeben oder – besser – prüfen, warum die App überhaupt fremd lädt
PWA lässt sich nicht installierenmanifest-src oder worker-src fehltBeide auf 'self' setzen, Worker zusätzlich blob:
Nach dem Update plötzlich kaputtDie App nutzt jetzt einen neuen Host oder Inline-CodeReports/Reports-Only-Zeitraum nutzen, Policy nachziehen – Version im Kommentar notieren
Nur ein Browser betroffenUngleiche Token-UnterstützungToken-Unterschiede prüfen, konservative Schreibweise wählen
Nicht in Panik alles freigeben. Der Reflex nach einem Fehler ist 'unsafe-inline' plus * – damit ist die Policy wertlos. Besser: Report-Only wieder einschalten, die fehlende Quelle in der Konsole suchen und genau diese eine ergänzen.

Häufige Fragen (FAQ)

FrageAntwort
Bricht CSP meine Seite?Nur in der scharfen Variante und nur, wenn die Policy etwas blockiert, das die App braucht. Im Report-Only-Modus passiert nichts.
Bringt CSP etwas ohne XSS-Lücke?Ja – sie schützt für den Fall, dass eine entsteht. Das ist derselbe Gedanke wie bei Backups: Vorbereitung für den unwahrscheinlichen Fall.
Kann ich 'unsafe-inline' ganz vermeiden?Bei eigenen Templates ja (Nonce oder Hash). Bei fremden Anwendungen meist nicht – dann bleibt es ein bewusster Kompromiss für Stile.
Warum sehe ich zwei CSP-Header?Weil Server- und Location-Ebene je einen senden. Der Browser setzt beide durch. Entferne einen davon, sonst sind Fehlersuchen vorprogrammiert.
Ersetzt CSP X-Frame-Options?Für moderne Browser ja. Beide parallel zu senden ist üblich und schadet nicht.
Ist CSP ein Rankingfaktor?Nein. Sie ist ein Sicherheitsmerkmal, keine SEO-Maßnahme.
Muss ich Reports speichern?Nein. Für Hobby-Setups reicht die Konsole. Sobald du Reports sammelst, sind es Nutzerdaten – dann gehören Aufbewahrung und Datenschutzerklärung dazu.
Gilt die Policy auch für HTTP/3?Ja. Header sind transportunabhängig – die Policy wird bei HTTP/1.1, HTTP/2 und HTTP/3 gleichermaßen beachtet.

Fazit

CSP ist der Header mit der größten Wirkung und dem größten Selbsterschütterungspotenzial – je nachdem, in welcher Reihenfolge man ihn einführt. Mit Report-Only, der Konsole als Report-Endpunkt und einer Policy, die pro Anwendung gebaut wird, ist es dagegen ein ruhiger Vorgang: beobachten, ergänzen, scharf schalten.

PairDrop und SkySend zeigen dabei die zwei Muster, die dir bei jedem selbst gehosteten Dienst begegnen: eine App mit WebRTC, bei der die Peer-Verbindungen außerhalb der CSP liegen, und eine App mit WebSocket-Uploads, bei der ein fehlender connect-src-Eintrag erst beim echten Transfer auffällt.

Die fünf Merksätze: ① CSP per HTTP-Header, nie per <meta> – sonst fehlen frame-ancestors und Reports. ② Immer erst -Report-Only, Funktionen durchklicken, dann scharf schalten. ③ object-src, base-uri und frame-ancestors kosten nichts und schließen drei Angriffsklassen. ④ Genau eine Policy pro Antwort – add_header vererbt nicht, Snippets verhindern den Salat. ⑤ WebRTC kann CSP nicht einschränken; WebSocket-Uploads dagegen schon – deshalb connect-src immer mit dem echten Upload testen.
📝
HuuuHosting-Redaktion

Open-Source-Tools für den eigenen Server – getestet, dokumentiert und in der Debian-13-Serie Schritt für Schritt erklärt.