Frontend-Sicherheit für Webanwendungen: XSS, Content-Security-Policy und der richtige Ort für Tokens
Wo XSS in React- und Astro-Apps noch eindringt, wie man eine Content-Security-Policy sicher einführt und warum Session-Tokens in HttpOnly-Cookies gehören.
Moderne Frameworks maskieren Ausgaben standardmäßig, und trotzdem passieren die meisten Sicherheitsvorfälle im Frontend weiterhin. Sie entstehen an den Ausnahmen: an der einen Stelle, die HTML einfügt, am Skript, das das Marketing eingebaut hat, am Token, das dort liegt, wo jedes Skript es lesen kann. Dieser Artikel behandelt zwei Themen, die in der Praxis den größten Unterschied machen: Cross-Site-Scripting (XSS) verhindern und eingrenzen, und entscheiden, wo Session-Tokens liegen.
Warum XSS das zentrale Problem ist
Wenn ein Angreifer sein JavaScript auf Ihrer Seite ausführen kann, läuft es mit den vollen Rechten des angemeldeten Nutzers. Es kann alles auf der Seite lesen, Ihre API mit der Sitzung des Nutzers aufrufen und verändern, was der Nutzer sieht. Die meisten anderen Schutzmaßnahmen im Frontend sollen das entweder erschweren oder weniger schädlich machen.
Es gibt drei verbreitete Wege hinein:
- Gespeichertes XSS (stored): Schädlicher Inhalt wird gespeichert (ein Kommentar, ein Profilfeld) und anderen Nutzern angezeigt.
- Reflektiertes XSS (reflected): Inhalt aus der URL oder einer Anfrage wird in die Seite zurückgespiegelt.
- DOM-basiertes XSS: Ihr eigener Client-Code nimmt Daten aus einer Quelle wie
location.hashoderpostMessageund schreibt sie unsicher in die Seite.
Wo XSS bei React und Astro noch eindringt
React und Astro maskieren Text in Templates. Das verbleibende Risiko sitzt an wenigen Stellen, nach denen Sie gezielt suchen können:
- Rohes HTML einfügen:
dangerouslySetInnerHTMLin React,set:htmlin Astro,innerHTMLin einfachen Skripten. Alles, was von Nutzern, einem CMS oder einer API dort ankommt, ist ein Risiko, solange es nicht bereinigt ist. - Gefährliche URLs:
<a href={value}>oder<iframe src={value}>mit einerjavascript:-URL. Übergeben Sie nur URLs, die Sie geprüft haben. - Gerendertes Markdown oder Rich Text von nicht vertrauenswürdigen Autoren, also getarntes HTML.
- Skripte von Dritten: Ein Analyse-, Chat- oder Werbeskript läuft mit denselben Rechten wie Ihr eigener Code, und seine Lieferkette wird zu Ihrer.
- Unsichere Senken im eigenen Code:
eval,new Function,document.writeund dynamische Skript-URLs.
Für die ersten beiden decken zwei kleine Schutzmaßnahmen die meisten Fälle ab:
import DOMPurify from "dompurify";
// Vor jedem Einfügen von rohem HTML bereinigen.
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.html) }} />
// Nur die erwarteten Protokolle erlauben.
const allowedProtocols = new Set(["http:", "https:", "mailto:"]);
export function safeUrl(value: string): string | undefined {
try {
const url = new URL(value, window.location.origin);
return allowedProtocols.has(url.protocol) ? url.href : undefined;
} catch {
return undefined;
}
}
Verwenden Sie eine Erlaubnisliste von Protokollen, keine Sperrliste schlechter. Und bereinigen Sie zusätzlich auf dem Server, wenn das HTML gespeichert wird, denn die Prüfung im Client schützt nur die Clients, die sie ausführen.
Content-Security-Policy als zweite Schicht
Eine Content-Security-Policy (CSP) ist ein HTTP-Header, der dem Browser sagt, welche Skripte und anderen Ressourcen die Seite laden oder ausführen darf. Gibt es eine XSS-Lücke, kann eine strenge Policy verhindern, dass das eingeschleuste Skript ausgeführt wird. Sie ersetzt korrektes Maskieren nicht. Sie begrenzt den Schaden, wenn das Maskieren versagt.
Ein strenger Ausgangspunkt für eine Seite mit eigenen Skripten:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self'
Was die Teile bewirken:
default-src 'self': Standardmäßig wird nur vom eigenen Origin geladen.script-srcmit einer Nonce: Nur Skripte mit einem frischen Zufallswert (pro Antwort erzeugt und in jedes<script nonce="...">eingetragen) dürfen laufen. Ein eingeschleustes Skript hat keine gültige Nonce.'strict-dynamic'erlaubt diesen vertrauenswürdigen Skripten, weitere Skripte nachzuladen.object-src 'none'undbase-uri 'none': schließen zwei alte Umgehungswege (Plugins und eingeschleuste<base>-Tags).frame-ancestors 'none': Niemand kann Ihre Seiten in einen Frame einbetten, was Clickjacking verhindert. Nutzen Sie'self', wenn Sie eigene Seiten einbetten müssen.form-action 'self': Formulare dürfen nur an Ihren Origin senden.
Vermeiden Sie 'unsafe-inline' und 'unsafe-eval' in script-src. Sie schalten genau den Schutz ab, den Sie wollen. Eine rein statische Seite kann keine Nonce pro Antwort erzeugen, sie nutzt deshalb Hashes ihrer Inline-Skripte ('sha256-…') oder verzichtet ganz auf Inline-Skripte.
Einführung ohne Ausfall
- Senden Sie die Policy zuerst als
Content-Security-Policy-Report-Only. Der Browser meldet Verstöße, ohne etwas zu blockieren. - Sammeln Sie die Berichte ein bis zwei Wochen und beheben Sie, was sie zeigen: Inline-Handler, vergessene Skripte von Dritten, eine von einem CDN geladene Schrift.
- Wechseln Sie zu
Content-Security-Policy, wenn die Berichte ruhig sind. - Lassen Sie den Report-Endpunkt weiterlaufen, damit eine spätere Änderung, die die Policy bricht, auffällt.
Für Skripte von Dritten, die sich nicht vermeiden lassen, ergänzen Sie Subresource Integrity (integrity="sha384-…" mit crossorigin="anonymous"), wenn die Datei versioniert und stabil ist. Der Browser lehnt dann eine veränderte Datei ab. Browser unterstützen außerdem Trusted Types (require-trusted-types-for 'script'), um gefährliche DOM-Senken einzuschränken; prüfen Sie die aktuelle Browserunterstützung, bevor Sie sich darauf verlassen.
Wo Session-Tokens liegen sollten
Nach dem Login hält das Frontend einen Identitätsnachweis: eine Session-ID oder ein Token. Wo Sie ihn ablegen, bestimmt, was eine XSS-Lücke anrichten kann.
| Speicher | Für Seitenskripte lesbar | Hauptrisiko |
|---|---|---|
localStorage / sessionStorage |
Ja | Jedes XSS kann das Token kopieren und anderswo benutzen |
Cookie ohne HttpOnly |
Ja | Dasselbe, und es wird zusätzlich automatisch gesendet |
Cookie mit HttpOnly |
Nein | Cross-Site Request Forgery (CSRF) |
| JavaScript-Variable im Speicher | Von Skripten derselben Seite | Beim Neuladen weg; XSS kann es trotzdem lesen |
localStorage ist beliebt, weil es einfach ist, und es ist die schwächste Wahl für ein langlebiges Token. Ein Skript, das auf Ihrem Origin läuft, kann es lesen, sodass eine einzige XSS-Lücke zur Kontoübernahme wird, die auch nach dem Schließen des Tabs weiterläuft.
Ein HttpOnly-Cookie kann JavaScript nicht lesen, also auch nicht herauskopieren. Ein vernünftiger Standard:
Set-Cookie: __Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax
Secure: wird nur über HTTPS gesendet.HttpOnly: für Skripte unsichtbar.SameSite=Lax: Der Browser sendet das Cookie nicht bei seitenübergreifenden POST-Anfragen, was die meisten CSRF-Angriffe verhindert. Nutzen SieStrict, wenn Ihre Abläufe es zulassen.- Das Präfix
__Host-zwingt den Browser,Secure,Path=/und keinDomainzu verlangen, sodass keine andere Subdomain es überschreiben kann.
Weil Cookies automatisch gesendet werden, schützen Sie zustandsändernde Anfragen zusätzlich gegen CSRF: Prüfen Sie den Origin-Header auf dem Server und verlangen Sie für sensible Aktionen ein CSRF-Token, das Angreifer nicht lesen können.
Seien Sie realistisch, was HttpOnly leistet. Es verhindert, dass ein Angreifer das Token stiehlt, aber eingeschleustes Skript kann weiterhin Anfragen senden, die das Cookie mitnehmen, solange die Seite offen ist. Deshalb kommt es nach XSS-Vermeidung und CSP, nicht anstelle davon.
Spricht Ihr Frontend mit einer separaten API oder einem Identity Provider, ist ein verbreitetes Muster ein kleines Backend for Frontend (BFF): Der Browser hält nur ein Session-Cookie für Ihr eigenes Backend, und das Backend verwahrt die OAuth-Tokens und ruft die API im Auftrag des Browsers auf. Die Tokens erreichen den Browser nie. Die IETF-Empfehlungen für browserbasierte OAuth-Anwendungen beschreiben das für die meisten Fälle als sicherste Variante.
Eine kurze Checkliste
- Suchen Sie im Code nach
dangerouslySetInnerHTML,set:html,innerHTML,evalundjavascript:. Jeder Treffer braucht eine Begründung. - Bereinigen Sie HTML von Nutzern oder aus dem CMS vor dem Rendern und prüfen Sie URLs gegen eine Protokoll-Erlaubnisliste.
- Senden Sie eine CSP auf Basis von Nonces oder Hashes, beginnen Sie mit Report-Only und vermeiden Sie
'unsafe-inline'für Skripte. - Ergänzen Sie in dieser Policy
frame-ancestors,object-src 'none'undbase-uri. - Halten Sie Session-Tokens in Cookies mit
Secure,HttpOnlyundSameSiteund schützen Sie zustandsändernde Anfragen gegen CSRF. - Behandeln Sie jedes Skript von Dritten als Teil Ihres Codes: Halten Sie die Liste kurz und prüfen Sie sie regelmäßig.
- Führen Sie
npm auditoder Entsprechendes in der CI aus und committen Sie Lockfiles. - Prüfen Sie Ihre Header nach jeder Änderung am Setup in den Entwicklerwerkzeugen des Browsers und mit einem Header-Scanner wie dem HTTP Observatory von Mozilla.