SecurityGENERALFRAMEWORK-SPECIFICSPEC-EVOLVING

Cross-Site Scripting

Untrusted content becomes executable content. Your framework already escapes text interpolation — so every XSS in a modern application is at the exact place someone opted out.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which places in my UI turn data into code, and what is my framework already doing about it?

The user intent

A person types a product review, a display name, a support message. They expect it to appear as text, exactly as typed, for everyone who reads it.

The obvious build

We render user content with innerHTML so formatting works, and we strip <script> tags on the way in. Anything else is over-engineering.

Why it breaks

Executable content is not only <script>. Event-handler attributes, javascript: URLs, <iframe srcdoc>, SVG <use> and <animate>, and a <style> block are all paths to behaviour that a <script>-shaped filter never sees.

How it breaks in a real browser
  • Executable content is not only <script>. Event-handler attributes, javascript: URLs, <iframe srcdoc>, SVG <use> and <animate>, and a <style> block are all paths to behaviour that a <script>-shaped filter never sees.
  • Stripping on input means the stored value is now neither the user's text nor safe HTML, and the moment a second surface renders it differently — an email, a native app, a CSV export — the assumption breaks in a place nobody is looking (Sanitization and Trusted HTML).
  • The dangerous string usually does not arrive through a form at all. A query parameter read into the DOM, a postMessage payload, a value out of localStorage, a field in an API response — all of them are untrusted, and only one of them looks like user input (The Same-Origin Policy).
  • Once script runs in your origin, everything the page can do it can do: read the DOM, read Web Storage, call your API with the user's cookies attached, and rewrite the interface the user is looking at (The Browser Security Model).
  • It is invisible in review. dangerouslySetInnerHTML reads as one line in a diff; the line is a decision to leave the framework's protection.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The bug is a context confusion: a value crosses from data into a context where the parser treats some characters as syntax. HTML text, an attribute value, a URL, a <script> body and a <style> body are five different parsers with five different dangerous characters (The HTML Tokenizer).
  • Stored XSS: the value is persisted and served to everyone who views the record. One injection, every viewer, and it survives until someone cleans the database.
  • Reflected XSS: the value comes in on the request — a query string, a path segment — and is echoed into the response or into the DOM. It needs a crafted link and it affects whoever follows one.
  • DOM-based XSS: the server is entirely innocent. Client code reads from a source it does not control and writes into a sink that executes. The server logs show a completely normal request (What a Mutation Costs).
  • Modern frameworks escape text interpolation by default. React {value}, Vue {{ value }}, Svelte {value} and Angular interpolation all produce text nodes, not markup. This is why the vulnerability class shrank so dramatically and why the remaining cases cluster so tightly.
  • Escaping is done by setting a property or creating a text node, not by finding characters and replacing them. el.textContent = value cannot produce an element no matter what value contains, because the value never goes through the HTML parser (The DOM Is Not Your HTML).
  • A sink is any API that parses a string as markup, as a URL, or as code. The list is finite and worth memorising, because "which sinks does this code touch" is the whole of a frontend security review.

What this makes the browser do

And which of it is avoidable.

  • Every assignment to innerHTML runs the full HTML parser and tree builder, then tears down and rebuilds the subtree — this is a real cost as well as a real risk (Tree Construction).
  • A rebuilt subtree loses node identity: listeners attached to the old nodes are gone, focus is lost, the accessibility tree is recomputed, and any element the user was interacting with no longer exists (Node Identity Across Updates).
  • Setting textContent avoids the parser entirely and produces one text node, which is cheaper on every axis.
  • A sanitizer running on the client is parsing the string a second time and walking the resulting tree — a cost that scales with content size and belongs off the render path where possible.
  • Avoidable work: repeatedly rendering the same untrusted string through innerHTML on every state change, when a text node set once would do.

From a string to executing code

Every instance of this bug follows the same five steps, and naming them is what turns a scary category into a review checklist. The useful property is that you can break the chain at any step, and the earlier you break it the cheaper it is.

Notice that the third step — the sink — is the only one that is a line of your code. Sources are numerous and mostly out of your hands; sinks are few and entirely in your hands. That asymmetry is why frontend XSS work is sink work.

The chain, and where to break it
  1. 1
    Source

    A value arrives from somewhere you do not control: a form field, a query parameter, a hash fragment, a postMessage, a storage value, an API response, a cookie.

    fails by Being classified as trusted because it came from your own API — which is only as trusted as everything that can write to it.

  2. 2
    Transport and storage

    The value is persisted or echoed. Whether it is stored (every viewer) or reflected (whoever follows a link) determines the blast radius, not the fix.

    fails by Filtering here instead of at render — the stored value now matches no context, and a second consumer renders it differently.

  3. 3
    Sink

    Application code hands the string to an API that parses it as markup, as a URL, or as code. This is the vulnerability; everything else is logistics.

    fails by Being invisible: a one-line prop, a helper component, a utility called renderRich.

  4. 4
    Parse

    The browser's HTML, URL or JavaScript parser turns characters into syntax. An attribute boundary, a javascript: scheme or a tag start becomes structure.

    fails by Doing exactly what it is specified to do. There is no parser bug to blame here.

  5. 5
    Execute

    The resulting node or navigation runs with your origin's authority: DOM access, storage access, credentialed same-origin requests.

    fails by CSP capping this step — the last chance, and the reason CSP is worth having even when your escaping is correct (Content Security Policy).

Break it at "Sink" and the other four steps stop mattering. Break it only at "Execute" and you are relying on a mitigation to hold.

The sinks, concretely

This is the list. It is short enough to hold in your head and specific enough to lint for, and knowing it converts a vague anxiety about user content into a mechanical property of a diff. Two things are worth noticing: several of these do not look like HTML at all, and framework interpolation is deliberately absent because it is the safe path.

Also notice the URL sinks. A value bound to href or src is not HTML and is not escaped by anything your framework does — it is a URL, and the dangerous part is the scheme.

Sinks, and the safe form of each
1// ── MARKUP SINKS: the string is parsed as HTML ──────────────────────
2el.innerHTML = value // sink
3el.outerHTML = value // sink
4el.insertAdjacentHTML('beforeend', v) // sink
5document.write(value) // sink, and it blocks the parser too
6<div dangerouslySetInnerHTML={{ __html: value }} /> // React opt-out
7<div v-html="value" /> // Vue opt-out
8{@html value} // Svelte opt-out
9
10// safe form: let the value stay data
11el.textContent = value
12<div>{value}</div> // React / Svelte: escaped, always
13<div>{{ value }}</div> // Vue: escaped, always
14
15// ── CODE SINKS: the string is parsed as JavaScript ──────────────────
16eval(value)
17new Function(value)
18setTimeout(value, 0) // string form only; a function is fine
19el.setAttribute('onclick', value)
20
21// safe form: there isn't one. Pass data, not code.
22setTimeout(() => run(value), 0)
23
24// ── URL SINKS: the string is parsed as a URL, and the scheme is code ─
25<a href={value}> // javascript: navigates AND executes
26<iframe src={value} /> // data: and javascript: both matter here
27<form action={value}> // and formaction on the submit button
28el.setAttribute('xlink:href', value) // SVG, and it is often forgotten
29
30// safe form: parse it and check the scheme against an allowlist
31function safeHref(raw: string, base = location.href): string | undefined {
32 let url: URL
33 try { url = new URL(raw, base) } catch { return undefined }
34 return url.protocol === 'https:' || url.protocol === 'mailto:'
35 ? url.href
36 : undefined // render as plain text, or drop the link entirely
37}

The URL block is the one that surprises people. Frameworks escape *text*; they do not inspect the value you bound to href, so a scheme check is application code you have to write.

What the developer actually sees

These bugs rarely announce themselves. They are found by review, by a report, or by a symptom that looks like something else entirely. The rows below are how this surfaces in practice, and each one is worth recognising early because the second column is usually attributed to a rendering bug first.

Symptoms, and what they are really telling you
TriggerSymptomCauseResponse
A user's display name contains angle bracketsThe name renders as blank, or as a broken elementThe value is going through innerHTML and being parsed as markupRender as text. The disappearing name and the vulnerability are the same bug wearing two hats.
A link in user content points at javascript:Clicking it runs code with the page's authorityThe value was bound straight to href with no scheme checkParse with URL and allowlist the scheme; render as text when it fails (What Native Elements Already Do).
A markdown renderer is passed raw HTML pass-throughNothing visible; the feature works as specifiedMost markdown libraries permit inline HTML by defaultDisable HTML in the renderer, or sanitize its output (Sanitization and Trusted HTML).
CSP report-only starts reporting inline scriptA stream of violations from a page with user contentEither an injection, or legitimate inline script you did not know aboutTriage by source. Both answers are worth having before you enforce (Content Security Policy).
A query parameter is written into the pageA crafted link behaves differently from a normal oneDOM-based reflection; server logs show nothing unusualTreat location as untrusted input everywhere it is read (The URL Is Application State).
The sanitizer was upgradedFormatting quietly disappears from old contentThe allowlist changed, and previously-permitted markup is now strippedPin and review sanitizer configuration explicitly rather than relying on its defaults.

How to build it

Most important first.

  • Default to text. If a value can be rendered as text, render it as text — {value}, textContent, a bound text attribute. This handles the overwhelming majority of user content and costs nothing (Semantics Are Behaviour).
  • Make the opt-outs greppable and reviewed. innerHTML, dangerouslySetInnerHTML, v-html, {@html}, outerHTML, insertAdjacentHTML, document.write, eval, new Function and setTimeout with a string argument should each be a lint rule with a named allowlist.
  • Validate URLs before they reach href, src, action, formaction or xlink:href. Parse the URL and check the scheme against an allowlist; a substring check for javascript: is defeated by whitespace and encoding that the parser normalises and your check does not.
  • Prefer structured content over HTML strings. Markdown rendered to a node tree you construct, or a small block schema, removes the sink instead of guarding it (What a Component Owes Its Caller).
  • If HTML must be rendered, sanitize with a maintained allowlist library at the point of rendering, not the point of storage (Sanitization and Trusted HTML).
  • Add a Content Security Policy as the second layer, and treat it as a cap on impact rather than a fix (Content Security Policy).
  • Keep session credentials out of script-readable storage where the architecture allows it, so a successful injection does not immediately become a portable session (Cookies vs Script-Readable Tokens).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Replacing a subtree with innerHTML destroys focus. If the user was inside that subtree, focus falls back to the document body and a keyboard or screen-reader user is dropped to the top of the page mid-task (Focus Management).
  • The same rebuild breaks ARIA relationships that point by id: aria-labelledby, aria-describedby, aria-controls and aria-activedescendant all become dangling references pointing at elements that no longer exist (The Rules of ARIA).
  • Injected content can supply role, tabindex and ARIA attributes, which means an injection is also an accessibility-tree injection — a screen-reader user can be told something entirely different from what is on screen (The Accessibility Tree).
  • Rendering untrusted HTML into a live region means an attacker chooses what is announced. Live regions should carry text you constructed, never a sanitized blob (Live Regions and Announcement).
  • When a sanitizer strips content, the resulting gap needs to be perceivable rather than an empty node — otherwise the failure is visible to sighted users as a blank space and invisible to everyone else.

What can go wrong

Failure modes
  • A denylist that filters <script> and on* attributes and misses javascript: in an href, a data: URL in an iframe, or an SVG event attribute. Denylists fail by omission and you find out from someone else.
  • Escaping applied in the wrong context: HTML-escaping a value that lands inside a URL, or inside a <style> block, produces a string that is safe for a parser you are not using (Custom Properties).
  • A "safe" wrapper component that takes an html prop and is used in eleven places, three of which pass a value that came from an API.
  • Server-rendered state serialised into the page — a JSON blob in a <script> tag — without escaping the sequences that terminate the script element (Server-Side Rendering).
  • The mitigation failing: a sanitizer configured with ALLOW_UNKNOWN_PROTOCOLS, or upgraded past a breaking change and silently reverted to defaults that permit more than intended.
  • A framework escape hatch used for a legitimate reason — an editor, a CMS field — that later becomes the general-purpose "render this string" component.
What can arrive out of order
  • Sanitization racing render: an optimistic update showing raw user content while the sanitized version is still being computed puts unsanitized markup on screen for a frame (Optimistic UI).
  • A CSP delivered via meta after the parser has already reached user-controlled markup does not cover it, so injection timing relative to policy delivery matters (Streaming HTML).
  • Two async writes to the same container with innerHTML can interleave so that the second overwrites a sanitized result with an unsanitized one from a slower earlier request (Out-of-Order Responses).
Security
  • Script executing in your origin has your origin's full authority: same-origin fetch with cookies attached, localStorage, the DOM, and the ability to rewrite what the user sees while doing it.
  • HttpOnly prevents a cookie being *read* by script; it does not prevent script sending an authenticated request from the page. The attacker does not need the token if they can use the session in place (Cross-Site Request Forgery).
  • CSP with nonces or hashes and no unsafe-inline blocks a large fraction of injected script, and is defeated by injections that reuse an already-allowed origin or an allowed nonce (Content Security Policy).
  • Trusted Types, where available, turns the sinks themselves into checkpoints so that an unaudited assignment throws instead of executing (Sanitization and Trusted HTML).
  • Exploitation technique, payload construction and the full taxonomy live in Security Engineering; this lesson is about which line of your UI code is the sink.
Misreads
  • "React is XSS-proof." React escapes text interpolation. It also ships dangerouslySetInnerHTML, does not validate href values you pass it, and will happily render a javascript: URL you constructed.
  • "We strip tags on input, so we are safe." Input filtering does not know the output context, and there are several output contexts. Escape on output, in the context you are writing into.
  • "Only user-submitted text is untrusted." Query parameters, hash fragments, postMessage data, storage values, and fields from your own API are all untrusted for this purpose.
  • "It is only reflected, so the impact is lower." A crafted link in an email lands in the victim's authenticated session exactly like a stored payload does.
  • "A CSP means we can render whatever we like." A policy limits what injected content can do. It does not stop the injection, and a misconfigured policy stops very little (Content Security Policy).
  • "HttpOnly cookies mean an injection cannot do anything." It cannot read the cookie. It can still make the request.

Measuring it, and what changes in the field

How you would see this
  • Grep is a genuine security tool here. A count of sink usages per repository, tracked over time, is the single most informative metric this lesson has.
  • CSP in report-only mode reports inline script execution and blocked sources from real browsers, which finds both injections and the surprising amount of legitimate inline script you did not know you shipped (Content Security Policy).
  • A linter — react/no-danger, vue/no-v-html, or an equivalent — turns each opt-out into a reviewed exception rather than a silent one (Choosing the Test Level).
  • Error tracking surfaces sanitizer throws and URL-parse rejections, which are the signal that untrusted content is arriving in a shape you did not expect (Frontend Error Tracking).
  • The Elements panel shows what actually landed in the DOM after sanitization, which is frequently not what the sanitizer configuration implies (A Mental Model of the Devtools).
Slow device, slow network, large data, old tab
  • On a large document, innerHTML re-parsing and a client-side sanitizer both scale with content size and both run on the main thread, so a long comment thread makes a security control into a responsiveness problem (Long Tasks).
  • On a slow device, sanitizing on every render rather than once at ingestion into component state is felt directly as input lag.
  • In a server-rendered application, the same value is escaped by two different systems with two different context models, and the mismatch between them is where the bug lives (Hydration Mismatch).
  • In an application with a rich-text editor, HTML rendering is a product requirement rather than a shortcut, and the whole weight moves onto sanitizer configuration.
What this costs
  • Text-only rendering removes the class entirely and removes formatting. When formatting is a genuine requirement, a structured content model costs more to build and is safe by construction.
  • Client-side sanitization is flexible and costs main-thread time on every render; server-side sanitization is cheaper per view and cannot see the rendering context the client will use.
  • Strict lint rules on sinks generate friction and exceptions. That friction is the mechanism: an exception someone had to justify is one someone read.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe context-confusion mechanism, the sink list and the default-escaping behaviour of mainstream frameworks hold across browsers; what varies is which exotic markup a given engine will still execute, which is why a denylist is unsafe on every one of them.
  • FRAMEWORK-SPECIFICThe name of the opt-out differs — dangerouslySetInnerHTML in React, v-html in Vue, {@html} in Svelte, [innerHTML] in Angular — and so does the surrounding behaviour: Angular sanitizes bound HTML by default through its own sanitizer, whereas React and Svelte insert the string with no sanitization whatsoever.
  • SPEC-EVOLVINGTrusted Types and the built-in Sanitizer API are shipping unevenly: Trusted Types is available in Chromium and not in Safari at the time of writing, so a design that depends on either needs a fallback and a feature check rather than an assumption.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — why a unit test proves a sanitizer configuration and cannot prove the absence of a sink, and what that means for where this belongs in a test strategy.