Abschnitt 20 · Lektion 3
Sicherheit
In Lektion 11.1 stand eine Regel ohne Begründung: Nimm textContent, nicht innerHTML. Hier kommt die Begründung nach, denn eine Regel, deren Grund man nicht kennt, bricht man beim ersten Mal, wenn die andere Variante bequemer ist.
Wie fremder Text zu fremdem Code wird
Stell dir ein Kommentarfeld vor. Jemand tippt etwas ein, es geht an den Server, wird gespeichert, und beim nächsten Aufruf zeigst du es allen anderen Besuchern an.
Für den Server ist das ein Text. Er speichert Zeichen, mehr passiert dort nicht. Interessant wird es erst in dem Moment, in dem du diesen Text in die Seite schreibst.
<h1>Kommentare</h1>
<div id="ausgabe"></div>
<p>Sieh in die Console.</p> body {
font-family: system-ui, sans-serif;
padding: 1rem;
}
#ausgabe {
border: 1px solid #d6d3d1;
min-height: 40px;
padding: 8px;
} // Das hier hat ein Besucher in ein Kommentarfeld getippt.
// Für den Server ist es nur ein Text wie jeder andere.
const vomBesucher = `<img src=x onerror="console.error('Hier läuft fremder Code.')">`;
const ausgabe = document.getElementById("ausgabe");
// Ein Zeichen zu viel: statt den Text zu setzen, wird er
// als Markup gelesen. Der Browser baut daraus ein Element,
// versucht das Bild zu laden, scheitert, und ruft dabei
// aus, was im onerror steht.
ausgabe.innerHTML = vomBesucher;
console.log("Elemente in der Ausgabe:", ausgabe.children.length); innerHTML bedeutet: lies das hier als Markup. Der Browser tut genau das. Er findet ein img, baut ein Element daraus, versucht das Bild zu laden, scheitert an der Adresse x und ruft aus, was im onerror steht.
An dieser Stelle läuft fremder Code in deiner Seite, mit allen Rechten, die dein eigener Code hätte. Er kann den Anmeldestatus auslesen, Formulare abschicken, den Inhalt der Seite austauschen oder Eingaben mitschneiden. Diese Angriffsart heißt Cross-Site-Scripting, kurz XSS, und sie steht seit zwanzig Jahren in jeder Liste der häufigsten Web-Schwachstellen.
Das Beispiel benutzt ein img, weil das ohne jeden Klick auskommt. Ein script-Tag würde übrigens nicht funktionieren: per innerHTML eingefügte script-Elemente führt der Browser nicht aus. Das klingt beruhigend und ist es nicht, denn onerror, onload und ein Dutzend weitere Attribute funktionieren sehr wohl.
textContent verhindert es strukturell
<h1>Kommentare</h1>
<div id="ausgabe"></div>
<p>Sieh in die Console.</p> body {
font-family: system-ui, sans-serif;
padding: 1rem;
}
#ausgabe {
border: 1px solid #d6d3d1;
min-height: 40px;
padding: 8px;
} const vomBesucher = `<img src=x onerror="console.error('Hier läuft fremder Code.')">`;
const ausgabe = document.getElementById("ausgabe");
// Ein Wort anders, und die spitzen Klammern sind nur noch
// spitze Klammern. Der Browser sucht hier gar nicht erst
// nach Markup.
ausgabe.textContent = vomBesucher;
console.log("Elemente in der Ausgabe:", ausgabe.children.length); textContent bedeutet: schreib das hier als Text. Der Browser sucht dabei gar nicht erst nach Markup. Aus <img …> werden Zeichen auf dem Bildschirm, und die Ausgabe hat danach null Kindelemente.
Das ist der Unterschied, auf den es ankommt: Bei textContent gibt es keinen Weg, der zufällig doch zu Code führt. Nicht weil der Browser den Angriff erkennt, sondern weil er an dieser Stelle prinzipiell kein Markup liest. Sicherheit, die aus der Bauweise folgt, ist die einzige Sorte, auf die man sich verlassen kann.
Dieselbe Frage an anderen Stellen
<h1>Dieselbe Frage an anderen Stellen</h1>
<div id="kasten"></div>
<p><a id="link" href="#">Sieht aus wie ein Link</a></p>
<p>Sieh in die Console.</p> body {
font-family: system-ui, sans-serif;
padding: 1rem;
}
#kasten {
border: 1px solid #d6d3d1;
min-height: 40px;
padding: 8px;
} const vomBesucher = `<img src=x onerror="console.error('Auch hier läuft fremder Code.')">`;
// 1. insertAdjacentHTML ist innerHTML mit anderem Namen.
document
.getElementById("kasten")
.insertAdjacentHTML("beforeend", vomBesucher);
// 2. Ein href, das mit javascript: anfängt, ist ein Skript.
// Klick den Link auf der Seite an.
document.getElementById("link").href =
"javascript:console.error('Und hier auch.')";
// 3. Säubern mit replace geht schief, und zwar leise.
function gesaeubert(text) {
return text.replace("<script>", "");
}
const trick = "<scr<script>ipt>alert(1)</script>";
console.log("Vorher: ", trick);
console.log("Nachher:", gesaeubert(trick)); innerHTML ist nicht die einzige Stelle, an der Text zu Markup wird.
insertAdjacentHTML tut dasselbe, nur an einer bestimmten Position. Das HTML im Namen ist die Warnung.
Ein href, das mit javascript: beginnt, ist ein Skript und kein Ziel. Wenn Nutzer irgendwo eine Adresse angeben dürfen, gehört diese Möglichkeit ausgeschlossen. Genau das übst du in der Aufgabe.
document.write ist der alte Weg mit demselben Problem, und eval ist die offene Tür schlechthin: Es führt einen Text als Programm aus. Für eval gibt es in normalem Anwendungscode keinen Grund, und diese Seite verbietet es sogar ausdrücklich.
Warum man nicht säubert
Der naheliegende Gedanke ist, den Text vorher zu putzen. Man wirft <script> heraus, ersetzt spitze Klammern, filtert onerror.
Der dritte Teil des Beispiels zeigt, warum das nicht trägt. "<scr<script>ipt>" wird durch das Entfernen von <script> erst zu einem <script>. Und das ist nur der einfachste Trick von vielen: Großschreibung, Umkodierung, Zeilenumbrüche mitten im Attributnamen.
Der Punkt ist ein grundsätzlicher: Beim Säubern musst du an alles denken, beim Nichtinterpretieren an nichts. Deshalb ist die Antwort nicht „gefährlichen Text erkennen”, sondern „Text gar nicht erst als Markup behandeln”.
Ausnahmen gibt es, und sie sind selten. Wer wirklich Markup vom Nutzer erlauben muss, etwa bei einem Editor mit Formatierungen, nimmt eine erprobte Bibliothek dafür und schreibt die Regeln nicht selbst.
Was eine Content Security Policy tut
Eine Content Security Policy ist eine Liste, die der Server als Kopfzeile mitschickt. Darin steht, aus welchen Quellen der Browser Skripte, Bilder und Stile laden darf, und was er verweigern soll.
Sie ersetzt nichts von dem, was oben steht. Sie ist die zweite Reihe für den Fall, dass die erste versagt: Selbst wenn fremder Code in die Seite kommt, kann er dann oft nicht nachladen und nichts nach draußen schicken.
Diese Seite hier hat eine, und du hast sie im Kurs schon zu spüren bekommen. Drei Zeilen in der Eingabezeile der Console führen es vor.
eval("1+1") antwortet EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive. Die Policy erlaubt kein unsafe-eval, und damit ist auch new Function("return 2+2") zu.
fetch("https://example.com/") scheitert mit TypeError: Failed to fetch (Firefox: NetworkError when attempting to fetch resource.). Erlaubt ist nur die eigene Herkunft. Merk dir diese Meldung, denn sie ist die unfreundlichste der drei: Eine von der Policy abgewiesene Anfrage sieht genauso aus wie ein Netzausfall oder ein Tippfehler in der Adresse. Woran es wirklich lag, steht nur in der Console der echten Entwicklerwerkzeuge.
Ein Modul aus einer Blob-Adresse zu importieren endet mit TypeError: Failed to fetch dynamically imported module: blob:null/… (Firefox: error loading dynamically imported module). Auch diese Quelle steht nicht in der Liste.
Nebenbei kannst du in derselben Zeile die Behauptung von weiter oben prüfen: Häng ein script-Element per innerHTML in ein div, und der Code darin läuft nicht. Ein onerror in einem img daneben läuft sehr wohl, und genau das ist der Grund, warum „keine script-Tags” als Schutz nichts wert ist.
Der Punkt, an dem alle stolpern
Alles in dieser Lektion schützt deine Besucher voreinander. Nichts davon schützt deinen Server.
Der Satz aus Lektion 13.3 gilt unverändert: Was im Browser passiert, ist keine Sicherheit. Jede Prüfung dort ist ein Dienst am Nutzer, damit er nicht erst nach dem Absenden erfährt, dass etwas fehlt. Wer den Server angreifen will, benutzt deine Oberfläche gar nicht, sondern schickt die Anfrage direkt.
Deshalb prüft der Server jede Eingabe erneut, immer, ohne Ausnahme. Und dieselbe Frage stellt sich dort noch einmal von vorn, nur mit anderen Namen: Text, der in eine Datenbankabfrage gerät, ist SQL-Injection, und Text, der in einen Systembefehl gerät, ist Command-Injection. Das Muster ist jedes Mal dasselbe: Daten landen dort, wo der Empfänger Anweisungen erwartet.
Zum Mitnehmen
innerHTML macht aus Text Markup. Wenn der Text von einem Besucher kommt, macht es aus seinem Text deinen Code.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe im Editor, direkt im Browser geprüft
Öffnet sich mit dem Basis Konto.