DOM-based XSS ist die XSS-Variante, die den Server niemals erreicht. Sie passiert vollständig im Browser — typischerweise, wenn JavaScript einen Wert aus dem URL-Fragment, aus localStorage, aus postMessage oder einer ähnlichen client-seitigen Quelle nimmt und ihn in einen gefährlichen DOM-Sink schreibt. Server-Logs zeigen nichts. WAFs sehen nichts. Klassische Eingabe-Validierung wirkt nicht. Dieser Artikel zeigt das Source-Sink-Modell, die häufigsten Vektoren und die modernen Browser-Antworten.

Was DOM-XSS unterscheidet

Bei Reflected/Stored XSS schreibt der Server schadhafter HTML in die Antwort. Bei DOM-XSS liest Client-JavaScript einen Wert aus einer Source und schreibt ihn in einen Sink, ohne dass der Server beteiligt ist.

Konkretes Beispiel:

JavaScript dom-xss-classic.js
// Single-Page-App liest URL-Hash und schreibt es in den DOM
const hash = window.location.hash.slice(1);  // Source
document.getElementById('content').innerHTML = `Willkommen, ${hash}!`;  // Sink

URL: https://app.example/#<img src=x onerror=alert(1)> → Browser navigiert, JS liest den Hash, schreibt ihn als HTML in den DOM. Browser parst das HTML, rendert das <img>-Tag, lädt das (nicht existierende) Bild, triggert onerror, führt alert(1) aus.

Server hat das Skript nie gesehen: der URL-Fragment (#...) wird vom Browser nicht zum Server geschickt. Server-Logs zeigen den Aufruf der Seite, aber nicht den Hash. WAFs filtern nichts.

Folgerungen:

  • Eingabe-Validierung auf Server-Seite schützt nicht.
  • WAFs schützen nicht.
  • Output-Encoding im Server-Template schützt nicht.

Die einzige wirksame Verteidigung gegen DOM-XSS liegt im Client-Code: Sources sicher verarbeiten, Sinks vermeiden oder sichern.

Sources: woher kommt User-Input im Client?

DOM-XSS-Sources sind alle Wege, über die User-kontrollierte Daten in JS-Variablen landen:

  • window.location.hash — URL-Fragment.
  • window.location.search — URL-Query-String.
  • window.location.pathname — URL-Pfad.
  • document.URL, document.documentURI — komplette URL.
  • document.referrer — vorherige URL.
  • window.name — Cross-Frame-Daten.
  • postMessage-Events — Cross-Origin-Daten.
  • localStorage, sessionStorage, IndexedDB — wenn der Inhalt von User-kontrolliert ist.
  • Cookies — wenn von User-Input gespeist.
  • <input>-Felder, Form-Daten — Live-Input während der Sitzung.
  • WebSockets, Server-Sent Events — Live-Daten-Streams.

Jede dieser Quellen kann von einer Angreifer:in beeinflusst werden — entweder direkt (URL) oder indirekt (vorherige XSS, kompromittierter Server, Cross-Site-Postnachricht).

Sinks: wo wird's gefährlich?

Sinks sind APIs, die Strings als Code oder als HTML interpretieren:

Direkte HTML-Insertion:

  • element.innerHTML = ... — klassischster Sink.
  • element.outerHTML = ....
  • element.insertAdjacentHTML(...).
  • document.write(...), document.writeln(...).

Attribut-Setter mit JS-Interpretation:

  • element.setAttribute('href', '...') — wenn die URL mit javascript: beginnt, wird das beim Klick als Code ausgeführt.
  • element.setAttribute('src', '...') in Script-Tags.

Direkter Code-Eval:

  • eval(...) — String wird als JS ausgewertet.
  • Function(...) — Konstruktor, der String als Funktion erzeugt.
  • setTimeout('code', ...), setInterval('code', ...) mit String-Argument (deprecated, aber existiert).

DOM-Manipulation mit interpretiertem Inhalt:

  • element.style = '...' — CSS-Expressions sind in modernen Browsern weitgehend deaktiviert, aber CSS-Imports und URL-Werte sind weiter Sinks.

Frame-Sinks:

  • iframe.src = '...'javascript:-URLs werden ausgeführt.
  • window.open(url, ...) mit javascript:-URL.
  • location = '...', location.href = '...'javascript:-URLs.

Storage-Sinks (indirekt):

  • Werte in localStorage schreiben, die später in einen anderen Sink fließen.
Sink-KategorieBeispieleSchutz
HTML-InsertioninnerHTML, outerHTML, document.writeSanitization oder textContent statt innerHTML
URL-Setterhref, src, locationURL-Schema validieren (kein javascript:)
Code-Evaleval, Function, setTimeout('str')Komplett vermeiden
CSS-Insertionelement.style = ... mit url(...)CSS-Werte validieren

Konkrete Sink-Patterns

innerHTML als häufigster Sink:

JavaScript innerHTML-sink.js
// Schadhaft
const name = new URLSearchParams(location.search).get('name');
document.querySelector('#greeting').innerHTML = `Hallo, ${name}!`;

// Sicher (textContent statt innerHTML)
document.querySelector('#greeting').textContent = `Hallo, ${name}!`;

// Sicher (Sanitization)
const clean = DOMPurify.sanitize(name);
document.querySelector('#greeting').innerHTML = `Hallo, ${clean}!`;

location.href-Setter mit User-URL:

JavaScript redirect-sink.js
// Schadhaft: javascript:-URLs möglich
const next = new URLSearchParams(location.search).get('next');
location.href = next;

// Sicher: URL-Schema validieren
const next = new URLSearchParams(location.search).get('next');
try {
  const url = new URL(next, location.origin);
  if (url.protocol === 'https:' || url.protocol === 'http:') {
    location.href = url.href;
  }
} catch { /* invalid URL */ }

postMessage-Empfang ohne Origin-Check:

JavaScript postmessage-sink.js
// Schadhaft: nimmt jede Nachricht entgegen
window.addEventListener('message', (event) => {
  document.body.innerHTML = event.data;  // jeder Iframe kann das triggern
});

// Sicher: Origin prüfen
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://trusted.example') return;
  // ... immer noch nicht direkt innerHTML, lieber strukturierte Daten
});

Warum SPAs besonders betroffen sind

Single-Page-Apps (React, Vue, Angular, Svelte) verarbeiten viel mehr im Client als Server-rendering-Apps. Daher haben sie strukturell mehr DOM-XSS-Risiko:

  • Client-Routing liest URL-Fragmente und -Parameter, rendert basierend darauf.
  • Lokale State-Verwaltung speichert User-Input in Redux/Pinia/etc., rendert mehrfach.
  • Komplexe Client-seitige Logik mit vielen Templating-Pfaden.
  • Drittanbieter-Komponenten (UI-Libraries, Analytics, Widgets) mit eigenem HTML-Output.

Aber: SPAs nutzen typischerweise Frameworks mit Auto-Encoding. Das wirkt gegen klassische innerHTML-Sinks — solange du nicht aktiv dangerouslySetInnerHTML, v-html, [innerHTML], {@html} nutzt.

Die größten DOM-XSS-Risiken in modernen SPAs:

  • Explizite Roh-HTML-Insertion in Markdown-Renderern, Rich-Text-Editoren, CMS-Content.
  • URL-Parameter in Click-Handlern für Redirects.
  • iframe-src-Setter in eingebetteten Widgets.
  • Drittanbieter-Komponenten, die nicht Auto-Encoding nutzen.

Vertieft pro Framework in xss-in-frameworks.

Schutz-Patterns

1. Sink-Vermeidung.

  • Statt innerHTML lieber textContent oder innerText.
  • Statt eval und Function lieber strukturierte Logik.
  • Statt setTimeout('code') lieber setTimeout(() => code).

2. URL-Schema-Validierung.

Wenn User-URLs in href oder location fließen, immer Schema prüfen. Nur http:, https:, mailto:, tel: u. ä. erlauben.

3. Source-Encoding.

Wenn Werte aus location.hash im DOM angezeigt werden, statt innerHTML per textContent rendern.

4. Sanitization vor innerHTML.

Wenn innerHTML unvermeidbar ist (Markdown-Output, Rich-Text-CMS), Sanitization-Library (DOMPurify) davorschalten. Vertieft in html-sanitization.

5. Content Security Policy.

CSP mit script-src ohne 'unsafe-inline' und ohne 'unsafe-eval' blockiert viele DOM-XSS-Folgen. Selbst wenn ein Sink injiziert wurde, kann das Skript nicht laufen, wenn CSP es nicht erlaubt. Vertieft in content-security-policy.

6. Trusted Types.

Modernste Browser-Antwort: alle DOM-Sinks akzeptieren nur explizit als sicher markierte Werte. Strukturelle Verhinderung von DOM-XSS. Vertieft in trusted-types.

7. Strikte Lint-Regeln.

ESLint-Plugins (eslint-plugin-security, eslint-plugin-no-unsanitized) warnen vor gefährlichen Sinks im Code. Im CI eingebaut, sieht man Probleme früh.

Erkennung in der Praxis

DOM-XSS ist deutlich schwerer zu finden als Server-XSS, weil:

  • Server-Logs zeigen nichts — keine Spuren im Backend.
  • WAFs blocken nichts — der Angriff erreicht das Backend nicht.
  • Burp-/ZAP-Scanner finden DOM-XSS nur eingeschränkt.

Die wertvollsten Werkzeuge:

Burp DOM Invader. Burp Suite Pro hat seit 2022 das DOM Invader-Plugin — eine im Browser eingebaute Erweiterung, die DOM-Sources und -Sinks markiert und Test-Payloads injiziert. Praktisch das beste Werkzeug für DOM-XSS-Pentesting.

OWASP ZAP DOM XSS Scanner. Plugin „Web Sockets Filter" hat ein Modul für DOM-XSS. Weniger ausgereift als Burp DOM Invader, aber Open Source.

Statische Analyse.

  • eslint-plugin-no-unsanitized — warnt vor innerHTML, outerHTML, document.write.
  • Semgrep-Regeln für DOM-XSS-Patterns.
  • CodeQL-Queries.

Manuelle Code-Reviews.

Suche im JS-Code nach:

  • innerHTML, outerHTML, insertAdjacentHTML.
  • document.write, document.writeln.
  • eval, Function, setTimeout('...').
  • location.href = ..., location.assign(...) mit User-Input.

Pro Treffer fragen: kommt der Wert aus einer User-kontrollierten Source?

Häufige Stolperfallen

DOM-XSS in SPAs ist oft die einzige verbleibende XSS-Klasse

In modernen Framework-Apps (React, Vue, Angular) ist klassisches Server-XSS durch Auto-Encoding weitgehend gelöst. Was bleibt, sind DOM-XSS-Klassen — vor allem an Stellen, wo Framework-Auto-Encoding bewusst umgangen wird. Pentest-Reports finden hier am häufigsten Bugs.

Server-Side-Rendering schützt nicht vor DOM-XSS

Wer Next.js, Nuxt oder SvelteKit nutzt und denkt: „Mein SSR macht alles sicher" — falsch. SSR rendert den initialen HTML; jeder Folge-Client-Code (Hydration, Client-Routes, dynamische Updates) hat dieselben DOM-XSS-Risiken wie eine reine SPA.

URL-Fragmente und das Hash-Routing

Hash-Routing (#/page/...) ist eine klassische DOM-XSS-Falle. URL-Fragmente werden nicht vom Server gesehen — wenn der Client-Router den Hash unsanitisiert verarbeitet, hast du eine reine Client-Schwachstelle. Moderne Frameworks (React Router, Vue Router) handhaben das meist sicher, aber Custom-Router-Code muss geprüft werden.

postMessage ohne Origin-Check ist sehr verbreitet

Cross-Frame-Kommunikation per postMessage ist häufig — und der Default-Code überprüft oft den event.origin nicht. Jeder iframe, der in deine Seite eingebettet ist (Werbe-iframe, Drittanbieter-Widget), kann postMessage-Events senden. Origin-Check ist Pflicht. Vertieft in CSRF/iframe-Kapitel.

JSON.parse ist sicher, eval() nicht

Ein häufiges Anti-Pattern: eval('(' + jsonString + ')') zum Parsen. Niemals nötigJSON.parse(jsonString) ist seit allen modernen Browsern verfügbar, sicher, und schneller. eval sollte aus dem Code verschwinden.

Self-XSS via Browser-Konsole

Manche Plattformen warnen Nutzer:innen beim Öffnen der DevTools-Konsole: „Stopp! Wenn dir jemand Code zum Pasten gegeben hat ... ". Das ist Anti-Social-Engineering, kein Anti-DOM-XSS. Trotzdem nützlich, weil Self-XSS-Scams (Bösewicht überredet Opfer, JS-Code in die Konsole zu pasten) ein realer Vektor sind.

Burp DOM Invader als Standard-Tool

DOM Invader (in Burp Pro) ist seit 2022 das beste Werkzeug für DOM-XSS-Tests. Es injiziert Canary-Strings in alle Sources, beobachtet, wo sie als JS, HTML oder URL interpretiert werden, und zeigt die Sinks im DevTools-Stack. Hat die Praxis von DOM-XSS-Hunting deutlich verbessert.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu XSS & Content Injection

Zur Übersicht