FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · Hoch
Sicherheitsforschung · FlawOpen

JavaScript-XSS: DOM, React und Express absichern

Nutzen Sie Text-APIs, bereinigen Sie HTML, prüfen Sie URLs und erzwingen Sie Trusted Types für DOM/React (CWE-79).

💡 Einfache Erklärung (ELI5)

Stellen Sie sich ein Regiepult im Theater vor. Behandelt die Inspizienz jeden Zettel aus dem Publikum als Bühnenanweisung, kann ein Zettel eine unerwartete Szene hinzufügen; als gewöhnlicher Dialog angezeigt bleibt er Text. Im Browser weisen innerHTML und Reacts dangerouslySetInnerHTML den DOM an, den gelieferten Text als Markup zu parsen, während textContent ihn wörtlich darstellt. Maskieren Sie normalen Text, bereinigen Sie nur ausdrücklich erlaubtes HTML und prüfen Sie URL-Schemata vor dem Erzeugen von Links.

Kernkonzepte & Begriffe

innerHTML vs textContent
Eine Zuweisung an innerHTML, outerHTML oder insertAdjacentHTML lässt den Browser den String als HTML parsen, ein darin enthaltenes <img onerror> wird also zu einem aktiven Element. textContent und createTextNode fügen nur Text ein; nichts im Wert wird je als Markup geparst.
Template literals in res.send()
Express' res.send() schickt Strings standardmäßig als text/html. Ein Template-Literal wertet ${req.query.q} vor dem Senden aus, die Antwort enthält also, was der Nutzer in den Query-String geschrieben hat. req.query.q kann auch ein Array oder Objekt sein, also den Typ prüfen.
dangerouslySetInnerHTML + DOMPurify
React maskiert Text in JSX automatisch; dangerouslySetInnerHTML ist der ausdrückliche Ausstieg. Den Wert durch DOMPurify.sanitize() schicken, das ihn mit dem browsereigenen Parser liest und Skripte, Event-Handler und gefährliche URLs entfernt, bevor React ihn einfügt.
javascript: URLs in href
JSX maskiert den Attributwert, aber href="javascript:..." ist eine gültige URL, die beim Klick Code ausführt. Mit new URL() parsen und nur http: und https: erlauben; der URL-Parser entfernt führende Leerzeichen und Tabs, die einfache Präfixprüfungen übersehen.
Trusted Types
Mit der CSP-Direktive require-trusted-types-for 'script' lehnen unterstützende Browser einfache Strings in Sinks wie innerHTML ab und akzeptieren nur TrustedHTML-Objekte aus einer selbst definierten Policy, typischerweise einer, die DOMPurify aufruft. Neue Sinks rutschen nicht mehr unbemerkt durch.

Framework-Zuordnungen

Schritt-für-Schritt Angriffsablauf

Schritt 1

Die Eingabe erreicht einen Sink

Eine Suchanfrage, ein per API geladenes Profilfeld oder ein von einem anderen Nutzer gespeicherter Link kommt als String an und geht an innerHTML, dangerouslySetInnerHTML, href oder res.send().

Schritt 2

Der Wert wird zu Markup

Mit dem harmlosen Testwert <img src=x onerror=alert(1)> erzeugen innerHTML und res.send() ein echtes <img>-Element; ein gespeicherter Link javascript:alert(1) wird zu einem anklickbaren Skript.

Schritt 3

Der Browser führt es aus

Das Bild lädt nicht und löst onerror aus, oder der Nutzer klickt den Link. Der Code läuft sofort, ohne weiteres Zutun des Angreifers.

Schritt 4

Das Skript handelt als die Website

Es läuft mit dem Origin der Seite: Es kann das DOM lesen, die APIs der Website mit den Cookies des Opfers oder Tokens aus localStorage aufrufen und ändern, was der Nutzer sieht.

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
// profile.jsx: jede Funktion unten lässt einen String als HTML parsen
import express from 'express';

const app = express();

// Express: der Query-Wert landet vor dem Senden im HTML
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML parst name als Markup
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React fügt das HTML genau so ein, wie es ankam
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// eine javascript:-URL läuft beim Klick auf den Link
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
GEHÄRTETER SICHERHEITS-PATCH
// profile.jsx: Text bleibt Text; HTML und URLs werden zuerst geprüft
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// nur Strings werden akzeptiert, und zwar HTML-maskiert
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent parst nie Markup
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// DOMPurify entfernt Skripte und Event-Handler vor dem Einfügen
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// nur http- und https-URLs bleiben übrig, alles andere wird zu #
function safeHref(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

export function Website({ url }) {
  return <a href={safeHref(url)}>Website</a>;
}

Checkliste für Engineering & Systemsicherheit

Quellen