Abschnitt 15 · Lektion 3
XSS beim Rendern
Diese Lektion sieht in einem Server-Kurs anders aus als in einem Browser-Kurs, und der Unterschied ist der Punkt: Hier baut der Server das HTML zusammen. Was er dabei einbaut, hat jemand anderes geschrieben, und ausgeliefert wird es an alle.
Gespeichert ist nicht gefährlich, ausgeliefert schon
// Ein gespeicherter Kommentar. Der Text ist genau der, den jemand ins
// Formular getippt hat, und gespeichert wurde er unverändert.
const kommentar = {
autor: "Mallory",
text: '<script>alert("übernommen")</script>',
};
function maskiere(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
console.log("So liefert der Server die Zeile heute aus:");
console.log(` <li><b>${kommentar.autor}</b>: ${kommentar.text}</li>`);
console.log("");
console.log("Und so, wenn er beim Ausgeben maskiert:");
console.log(` <li><b>${maskiere(kommentar.autor)}</b>: ${maskiere(kommentar.text)}</li>`);
console.log("");
console.log("Im Browser steht in der zweiten Zeile sichtbar der Text");
console.log('<script>alert("übernommen")</script>, und in der ersten passiert er.'); Ein Kommentar liegt in der Datenbank, und darin steht ein script-Element. In der Datenbank richtet
es keinen Schaden an. Es ist Text, so wie „Schöner Artikel” Text ist. Gefährlich wird es genau in
dem Moment, in dem der Server es in ein Dokument legt und ausliefert, denn ab da ist es kein Text
mehr, sondern ein Teil der Seite.
Der Unterschied zwischen den beiden Zeilen im Beispiel ist die ganze Lektion. Oben wird das Element
zu einem Element. Unten sind aus < und > die Zeichenfolgen < und > geworden, und der
Browser zeigt sichtbaren Text an, statt etwas auszuführen.
Man unterscheidet zwei Formen, und beide entstehen an derselben Stelle:
- Gespeichertes XSS ist der Fall aus dem Beispiel. Der Text liegt in der Datenbank und wird jedem ausgeliefert, der die Seite aufruft. Ein einziger Kommentar erwischt alle Leser.
- Reflektiertes XSS kommt aus der Anfrage selbst zurück, meist über einen Suchbegriff in einer Fehlermeldung. Es erwischt nur den, der auf den präparierten Link klickt, dafür braucht es keinen Schreibzugang.
Warum beim Ausgeben und nicht beim Speichern
Es liegt nahe, den Text schon beim Speichern zu entschärfen und die Sache damit ein für alle Mal zu erledigen. Es ist trotzdem falsch, und zwar aus einem sehr praktischen Grund: Dieselben Daten landen an verschiedenen Stellen, und jede hat ihre eigenen Regeln.
Derselbe Kommentar geht in die HTML-Seite, in die JSON-Antwort deiner API, in die
Benachrichtigungsmail und irgendwann in einen CSV-Export. In JSON ist < völlig harmlos und
< schlicht falsch, denn dort liest ein Programm mit und bekommt einen Text, den nie jemand
geschrieben hat. Wer beim Speichern maskiert, hat die Entscheidung getroffen, bevor er wusste, wohin
die Daten gehen.
Dazu kommt, dass maskierte Daten in der Datenbank nicht mehr suchbar und nicht mehr vergleichbar
sind, und dass eine zweite Maskierung beim Ausgeben aus &lt; macht. Die Regel lautet deshalb:
roh speichern, beim Ausgeben für genau diese Ausgabe aufbereiten.
Fünf Zeichen, und eins davon zuerst
// Fünf Zeichen müssen weg, und eins davon ist besonders: das
// kaufmännische Und leitet jede Maskierung ein und muss deshalb zuerst
// dran sein.
function richtig(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function falsch(text) {
return String(text)
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'")
.replaceAll("&", "&");
}
for (const eingabe of ["Tee & Kekse", "<b>fett</b>", 'sagt "hallo"', "Wolf's Blog"]) {
console.log(`Eingabe ${eingabe}`);
console.log(` richtig ${richtig(eingabe)}`);
console.log(` falsch ${falsch(eingabe)}`);
} Die fünf Zeichen sind &, <, >, " und '. Vier davon sind offensichtlich, das fünfte ist die
Falle: Das kaufmännische Und leitet jede Maskierung ein, und wer es zuletzt ersetzt, maskiert die
eigene Arbeit noch einmal. Aus <b> wird dann &lt;b&gt;, und der Leser sieht auf der
Seite wörtlich <b> stehen. Kein Sicherheitsproblem, aber ein Fehler, den Nutzer melden und
den man schwer findet.
Der erste Fall zeigt außerdem, dass Maskieren nicht nur gegen Angriffe hilft. „Tee & Kekse” ist harmlos gemeint und trotzdem in HTML nicht das, was dasteht.
Drei Stellen, und die dritte ist anders
function maskiere(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function sichereUrl(roh) {
try {
const url = new URL(roh);
return ["http:", "https:"].includes(url.protocol) ? maskiere(url.href) : "#";
} catch {
return "#";
}
}
const boese = {
text: '<img src=x onerror="alert(1)">',
autor: 'Mallory" onmouseover="alert(1)',
seite: "javascript:alert(1)",
};
console.log("1. Im Textinhalt");
console.log(` roh <p>${boese.text}</p>`);
console.log(` maskiert <p>${maskiere(boese.text)}</p>`);
console.log("");
console.log("2. In einem Attribut");
console.log(` roh <a title="${boese.autor}">Profil</a>`);
console.log(` maskiert <a title="${maskiere(boese.autor)}">Profil</a>`);
console.log("");
console.log("3. In einer Adresse");
console.log(` roh <a href="${boese.seite}">Webseite</a>`);
console.log(` maskiert <a href="${maskiere(boese.seite)}">Webseite</a>`);
console.log(` geprüft <a href="${sichereUrl(boese.seite)}">Webseite</a>`);
console.log(` harmlos <a href="${sichereUrl("https://anna.beispiel.de")}">Webseite</a>`); Im Textinhalt reicht Maskieren. Aus dem img-Element wird sichtbarer Text, das onerror löst
nie aus.
Im Attribut reicht Maskieren ebenfalls, und man sieht gut, warum das Anführungszeichen dazu
gehört: Ohne es endet das title-Attribut mitten im Namen, und dahinter steht plötzlich ein
onmouseover, das der Browser als Ereignis versteht. Das ist ein vollständiger XSS ohne ein
einziges script-Element. Deshalb gehört auch jeder Attributwert in Anführungszeichen, immer.
In einer Adresse hilft Maskieren gar nichts, und der dritte Fall beweist es: In
javascript:alert(1) kommt keins der fünf Zeichen vor, die maskierte Fassung ist Zeichen für
Zeichen dieselbe. Eine Adresse ist keine Zeichenkette, die man entschärft, sondern eine Struktur,
die man prüft. new URL(...) zerlegt sie, und dann entscheidet eine Liste, welche Schemata
durchgehen. Alles andere wird zu #. Das ist wieder die Allowlist aus 15.1, nur an einer Stelle,
an der man sie leicht übersieht.
Es gibt noch eine vierte Stelle, und die ist der Grund, warum man in echten Projekten eine
Template-Engine benutzt statt selbst zusammenzusetzen: innerhalb eines script-Blocks gelten
noch einmal andere Regeln, und dort ist Maskieren nach HTML-Art nicht nur wirkungslos, sondern
kaputt. Wer Daten in JavaScript einbetten muss, macht das über JSON.stringify und über ein
<script type="application/json">, das der Browser nicht ausführt.
Und jetzt einmal ohne Zwischenschritt
Alles bisher stand als Text in der Ausgabe: die rohe Zeile, die maskierte Zeile, der Unterschied. Das nächste Beispiel startet einen echten Server, und der Reiter „Browser” lädt ihn. Damit siehst du nicht mehr das Markup, sondern seine Wirkung.
import { createServer } from "node:http";
// Genau der Text, den Mallory ins Formular getippt hat. Statt eines
// alert steht hier etwas Sichtbares: Der Rahmen der Seite laeuft
// ohne allow-modals, ein alert bliebe wirkungslos. Der Schaden ist
// derselbe, nur sieht man ihn.
const kommentar = {
autor: "Mallory",
text:
"<scr" + "ipt>" +
'document.title = "\u00fcbernommen";' +
'document.body.style.background = "#7f1d1d";' +
'document.body.style.color = "white";' +
'document.body.append("Dieser Satz kam aus einem Kommentarfeld.");' +
"</scr" + "ipt>",
};
function maskiere(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
const seite = [
"<!doctype html>",
'<meta charset="utf-8">',
"<title>Kommentare</title>",
"<h1>Kommentare</h1>",
"<h2>So liefert der Server sie heute aus</h2>",
`<ul><li><b>${kommentar.autor}</b>: ${kommentar.text}</li></ul>`,
"<h2>Und so, wenn er beim Ausgeben maskiert</h2>",
`<ul><li><b>${maskiere(kommentar.autor)}</b>: ${maskiere(kommentar.text)}</li></ul>`,
].join("\n");
createServer((req, res) => {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(seite);
}).listen(process.env.PORT ?? 3000); Oben steht die Fassung ohne Maskierung, darunter dieselbe Zeile maskiert. Starte die Sandbox und
schau in den Reiter „Browser”: Der Hintergrund ist rot, der Reiter selbst trägt jetzt den Titel
„übernommen”, und unten steht ein Satz, den der Server nie geschrieben hat. Alles drei kommt aus
einem Kommentarfeld. In der zweiten
Liste steht dagegen sichtbar der Text <script>...</script>, und mehr passiert nicht.
Statt eines alert steht in dem Kommentar etwas Sichtbares, und das hat einen Grund: Der Rahmen, in
dem die Seite läuft, hat kein allow-modals, ein alert bliebe darin wirkungslos. Am Schaden
ändert das nichts. Ein Skript, das den Hintergrund umfärben kann, kann auch das Anmeldeformular
austauschen.
Was ein XSS anrichtet, und was ihn bremst
Ein Skript, das in deiner Seite läuft, ist deine Seite. Es liest den Inhalt, es schickt Anfragen mit
den Cookies des Lesers, es tauscht das Anmeldeformular aus. Eine Sache kann es nicht: an ein Cookie
kommen, das HttpOnly gesetzt hat. Das ist die Zeile aus 13.3, und hier zeigt sich ihr Wert. Der
Angreifer kann dann immer noch Anfragen im Namen des Lesers schicken, aber er kann die Sitzung nicht
mitnehmen und später in Ruhe benutzen.
Die zweite Bremse ist eine Kopfzeile, Content-Security-Policy, und die kommt in 15.7 dazu. Beide
sind zweite Verteidigungslinien. Die erste ist und bleibt: beim Ausgeben maskieren.
Zum Mitnehmen
Maskiert wird beim Ausgeben, nicht beim Speichern. Dieselben Daten landen mal in HTML, mal in JSON, mal in einer E-Mail, und jede dieser Stellen hat ihre eigenen Regeln.
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.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 4 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.