Warum auch eine statische Seite Sicherheit braucht
Man denkt leicht, eine statische Seite biete keine Angriffsfläche. Es gibt keinen Servercode zum Ausnutzen und keine Datenbank zum Auslesen. Aber die Seite führt in jedem Browser JavaScript aus, und genau dort entsteht Cross-Site-Scripting (XSS).
Meine Seite baut Teile der Seite aus Daten: Kenntnisse, Zertifikate und die Liste der Artikel. Die erste Version fügte diese Daten mit innerHTML ein. Das ist unproblematisch, solange ich alle Daten selbst schreibe, aber ein einziger schlechter Titel oder Link, den ich irgendwo kopiere, würde für jeden, der die Seite öffnet, als Code laufen.
Was schiefgehen konnte
- XSS über Inhalte: ein Artikeltitel wie
<img src=x onerror=...>läuft, sobald die Seite ihn darstellt. - Bösartige Links: eine
javascript:- oderdata:-URL in einem Link führt beim Klick Code aus. - Code von Dritten: Schriften oder Skripte von fremden Domains können sich ändern, Besucher verfolgen oder kompromittiert sein.
- Reverse Tabnabbing: eine in einem neuen Tab geöffnete Seite kann meinen ursprünglichen Tab umleiten.
Was ich geändert habe
1. Inhalte sind immer Text, nie HTML. Jedes Element wird mit createElement gebaut und mit textContent gefüllt. Der Browser behandelt die Daten als Text, Markup darin wird also angezeigt, nicht ausgeführt.
2. Eine strenge Content Security Policy. Der Browser lädt Skripte, Stile, Schriften und Bilder nur von meiner eigenen Seite. Inline-Skripte, eval, Plugins und Formulare sind blockiert.
default-src 'none'; script-src 'self'; style-src 'self';
font-src 'self'; img-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'none';
require-trusted-types-for 'script'; trusted-types 'none'
3. Trusted Types. Die letzten beiden Zeilen der Policy lassen den Browser jede rohe HTML-Zuweisung verweigern, etwa innerHTML. Selbst wenn ich das alte Muster später versehentlich wieder einbaue, gibt es einen Fehler statt einer Sicherheitslücke.
4. Eine Allowlist für URLs. Jeder Link läuft durch eine Funktion, bevor er auf die Seite kommt. Nur https:-Links und Pfade auf meiner eigenen Seite kommen durch.
function safeUrl(value) {
const u = new URL(value, location.href);
if (u.protocol === "https:") return u.href;
if (u.origin === location.origin) return u.href;
return null; // javascript:, data: und alles andere wird verworfen
}
5. Keine Dritten. Die Schriften liegen auf der Seite selbst, und die Seite stellt keine Anfragen an andere Domains. Es gibt keinen fremden Code, dem man trauen müsste, und kein Tracking der Besucher.
6. Die Kleinigkeiten. Ausgehende Links nutzen rel="noopener noreferrer", die Referrer-Policy ist no-referrer, und /.well-known/security.txt sagt, wie man ein Problem melden kann.
Wie ich getestet habe
Ich habe echte Payloads in die Daten der Seite eingebaut, sie lokal ausgeliefert und in Chromium mit aktiver Policy geladen.
| Payload | Wo | Ergebnis |
|---|---|---|
<img src=x onerror=...> | Artikeltitel | Als Text angezeigt |
<script>...</script> | Artikel-Tag | Als Text angezeigt |
"><svg onload=...> | Artikelzusammenfassung | Als Text angezeigt |
javascript:... | Artikel- und Lebenslauf-Link | Link entfernt |
data:text/html,... | Artikel-Link | Link entfernt |
Inline-<script> im HTML | Seitenquelltext | Von CSP blockiert |
eval() | Skript der Seite | Von CSP blockiert |
Zuweisung an innerHTML | Live-Seite | Von Trusted Types blockiert |
Keine der Payloads wurde ausgeführt. Die letzte Prüfung habe ich nach dem Ausrollen auf der Live-Seite wiederholt.
Was noch fehlt
GitHub Pages erlaubt keine eigenen HTTP-Antwort-Header. Manche Schutzmaßnahmen funktionieren nur als Header und nicht als <meta>-Tag: frame-ancestors gegen Clickjacking, Strict-Transport-Security und Permissions-Policy. Das Risiko ist gering, weil die Seite nichts bietet, wozu man jemanden per Klick-Trick verleiten könnte, aber es bleibt eine Lücke.
Die Lösung ist, die Seite von einem Ort auszuliefern, an dem ich die Header kontrolliere. Das ist die nächste Ausgabe.