mitmario.dev

XSS beim Rendern

Node.js Sandbox 4 Min Lesezeit 4 BeispieleLektion 3 von 8

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

Der Kommentar, der kein Kommentar ist
// 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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

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 &lt; und &gt; 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 &lt; 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 &amp;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, und die Reihenfolge zählt
// 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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function falsch(text) {
  return String(text)
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;")
    .replaceAll("&", "&amp;");
}

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 &amp;lt;b&amp;gt;, und der Leser sieht auf der Seite wörtlich &lt;b&gt; 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

Drei Stellen, drei Regeln
function maskiere(text) {
  return String(text)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

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.

Dieselbe Zeile, einmal im echten Browser
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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

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, kostenlos

Zu 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.