---
key: "dom-xss-postmessage"
title: "Prevent DOM XSS, postMessage abuse and client-side injection"
url: "https://softwaresecurity.com/domains/appsec/browser-security/dom-xss-postmessage/"
api: "https://softwaresecurity.com/api/v1/articles/dom-xss-postmessage.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-79","CWE-95","CWE-1336","CWE-346","A05:2025","V1.2","V1.3","V3.2","V3.5","CAPEC-588","CAPEC-63"]
tags: ["Injection","Cross-site scripting (XSS)","Browser security","Secure defaults and frameworks","JavaScript and Node.js"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Prevent DOM XSS, postMessage abuse and client-side injection

Trace attacker-controlled values from the URL, messages and storage to the browser APIs that parse HTML or run code, then close those sinks with safe APIs, a sanitizer, Trusted Types and strict message checks.

Canonical URL: https://softwaresecurity.com/domains/appsec/browser-security/dom-xss-postmessage/

## In short

DOM-based XSS happens when page script copies attacker-controlled data, such as `location.hash`, a `postMessage` payload or stored JSON, into an API that parses HTML or runs code: `innerHTML`, `document.write`, `eval`, string timers, `javascript:` URLs or jQuery `html()`. Write text with `textContent`, sanitize unavoidable HTML with DOMPurify, and enforce Trusted Types with `require-trusted-types-for 'script'`. Accept messages only from an exact origin and the expected window, then validate their shape.

- Treat `location`, `document.referrer`, `window.name`, web storage and message data as attacker-controlled. The URL fragment never reaches your server, so server-side filters and logs never see it.
- Replace sinks rather than filter input: `textContent` instead of `innerHTML`, functions instead of strings in timers, parsed and checked URLs instead of raw `href` assignments.
- Trusted Types has been Baseline since February 2026 (Chrome and Edge 83, Firefox 148, Safari 26). Under `require-trusted-types-for 'script'`, a plain string at a sink throws unless a named policy produced it.
- Compare `event.origin` with an exact origin, check `event.source`, then validate the message type and fields. When the data matters, send with an explicit `targetOrigin`, never `'*'`.
- DOM clobbering turns scriptless markup into script gadgets: keep configuration in `const` bindings, check types with `instanceof`, and prefix or strip `id` and `name` values in user HTML.
- AngularJS evaluates `{{ }}` in server-rendered text inside `ng-app` even after HTML escaping. Wrap that text in `ng-non-bindable` and migrate; support ended in January 2022.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| `Content-Security-Policy` (enforce) | `require-trusted-types-for 'script'; trusted-types app-html dompurify` | List only the policy names the page creates. DOMPurify parses through its own `'dompurify'` policy; without that name it cannot parse and returns an empty string. | W3C Trusted Types WD (7 Oct 2026) 4.2; tested Chrome 154, DOMPurify 3.4.16 |
| `Content-Security-Policy-Report-Only` (rollout) | `require-trusted-types-for 'script'; report-uri /csp-reports` | Reports every string that reaches a sink, as `'sink\|first 40 characters'`, without blocking it. | W3C Trusted Types WD 4.2.4 |
| Default policy | Not set, or only while migrating | If present, it receives every string that would otherwise throw. Make it sanitize HTML and return null for script and script URLs; a pass-through default policy disables the protection. | W3C Trusted Types WD 2.3.5 |
| `postMessage` receiver | `event.origin === 'https://partner.example' && event.source === frame.contentWindow` | No `indexOf`, `startsWith`, `endsWith` or unanchored regular expressions, and never accept the origin `'null'`. Validate the data's type and fields next. | HTML Standard 9.3.2.1; ASVS 5.0.0 3.5.5 |
| `postMessage` sender | `targetOrigin` set to the exact receiving origin | With `'*'`, a frame that has navigated to another site still receives the message (tested Chrome 154). | HTML Standard 9.3.2.1 |
| DOMPurify | 3.4.x with `SANITIZE_NAMED_PROPS: true` when user HTML may carry `id` or `name` | The default `SANITIZE_DOM` only removes names that collide with built-in document and form properties. | OWASP DOM Clobbering Prevention Cheat Sheet; tested 3.4.16 |
| jQuery | 4.0.0 where Trusted Types is enforced; never below 3.5.0 | jQuery 3.x fails to initialize under enforcement (tested 3.4.1 and 3.7.1). Before 3.5.0, `html()` rewrote markup that a sanitizer had judged inert (CVE-2020-11022, CVE-2020-11023). | jQuery 4.0.0 release notes; CISA KEV |

## Verify it

1. Search for `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`, `eval`, `new Function`, string `setTimeout` and `setInterval`, `.html()`, `$(string)`, `dangerouslySetInnerHTML`, `v-html`, `bypassSecurityTrust` and `{@html}`, and trace each argument back to its source.
2. Send `Content-Security-Policy-Report-Only: require-trusted-types-for 'script'` on every page and work through the reports; each one names a sink and the start of the string that reached it.
3. Load each page with payloads in the fragment, the query string and `window.name`, and assert in a headless browser that no element was created and no canary variable was set.
4. From a page on another origin, frame or open every page that listens for messages and post hostile and malformed messages; nothing may change on the page.
5. Put scriptless markup with `id` and `name` values such as `currentScript` and your configuration globals into every field that accepts HTML, then check that script URLs and settings are unchanged.
6. Search server templates for `ng-app` and Vue mount points that contain user content, and add a regression test in which `{{7*7}}` renders literally.

## What you will learn

- Trace untrusted values from browser sources to sinks and rate the risk.
- Replace HTML, code and URL sinks with safe APIs, and sanitize where HTML is a feature.
- Roll out Trusted Types from report-only mode to enforcement.
- Validate `postMessage` origin, source and data, and remove DOM clobbering and AngularJS template injection risks.
- Write browser tests that prove a sink no longer runs input.

Audience: Front-end developers, AppSec engineers and reviewers who own browser code in single-page or server-rendered applications

Before you start:

- Read JavaScript that manipulates the DOM.
- Know how reflected and stored XSS work and what the same-origin policy separates.
- Be able to set response headers in your server or CDN.

Time: About 33 minutes: 10 to read and 23 for the exercise

## DOM XSS happens entirely in the browser

In DOM-based XSS the server can send a perfectly safe page. The flaw is in the page's own script, which reads an attacker-controlled value (the source) and passes it to an API that parses HTML or compiles code (the sink). Browsers never send the URL fragment to the server, so an attack carried in `location.hash` never appears in server logs, WAF rules or server-side output encoding.

The script below has one source and three sinks. `innerHTML` parses the decoded fragment, so an `img` element with an `onerror` handler runs; `script` elements inserted through `innerHTML` do not run, which is why payloads use event handlers. `setTimeout` compiles a string argument like `eval`, so a campaign value that closes the quote runs too. The help link accepts a `javascript:` URL that runs on click. The browser percent-encodes `<` and `>` in the fragment; `decodeURIComponent` turns them back into markup.

```js
// welcome.js on https://app.example.com/welcome
// Attack: /welcome?campaign=x%22);alert(1);//&help=javascript:alert(1)#<img src=x onerror=alert(1)>
const params = new URLSearchParams(location.search);
const name = decodeURIComponent(location.hash.slice(1));

document.querySelector('#greeting').innerHTML = 'Welcome back, ' + name;  // HTML sink
setTimeout('track("' + params.get('campaign') + '")', 0);                 // code sink
document.querySelector('#help').href = params.get('help') ?? '/help';     // javascript: URL sink
```

## Know the sources, replace the sinks

A source is any value that another site or user can influence: `location` and `document.URL`, `document.referrer`, `window.name`, `postMessage` data, cookies, web storage, and JSON from your own API that contains other users' input. That last source makes the bug stored rather than reflected. Sinks are fewer and easier to find, so review starts there. Use the replacement in the table, and treat every sink you keep as code that needs a sanitizer or a Trusted Types policy. The sanitizer lab at /labs/html-sanitizer/ practises the configuration for user-supplied HTML.

| Sink | What goes wrong | Use instead |
| --- | --- | --- |
| `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`, `createContextualFragment`, iframe `srcdoc` | The string is parsed as markup, so event handlers run | `textContent`, or `createElement` with fixed attribute names; a sanitizer when HTML is a feature |
| `eval`, `new Function`, `setTimeout` and `setInterval` with a string, `setAttribute('on…')` | The string is compiled as JavaScript | Pass functions, parse data with `JSON.parse`, attach handlers with `addEventListener` |
| `location`, `a.href`, `form.action`, `iframe.src`, `window.open` | `javascript:` URLs run code when followed | `URL.parse()` against your origin, then allow only `http:` and `https:` on expected hosts |
| `script.src`, `import()`, `Worker` and `importScripts` URLs | Code loads from the given URL | Fixed URLs, or an allowlist of your own paths |
| jQuery `html()`, `append()`, `$(string)` and similar helpers | Strings that look like HTML are parsed | `.text()`, or sanitized HTML with jQuery 3.5.0 or later |

## Fix: keep data as text, functions and checked URLs

The fixed script keeps every input as data. `textContent` creates a text node, so the payload is displayed character for character. The timer receives a function that closes over the value. The help link is parsed against the page's origin and assigned only if it stays on that origin, which rejects `javascript:`, `data:` and other sites. `URL.parse()` returns `null` instead of throwing on malformed input and is available in Chrome and Firefox 126 and Safari 18.

Fixing the sink beats filtering the input; /domains/appsec/secure-coding/xss-output-encoding/ covers the server-rendered side. A blocklist of tags or of the word `script` misses event handlers, SVG, entity encoding and parser quirks, and it mangles legitimate text.

```js
// welcome.js, fixed: the same inputs now stay data
const params = new URLSearchParams(location.search);
const name = decodeURIComponent(location.hash.slice(1));

document.querySelector('#greeting').textContent = 'Welcome back, ' + name;  // text node

const campaign = params.get('campaign') ?? '';
setTimeout(() => track(campaign), 0);                                       // a function

const help = URL.parse(params.get('help') ?? '/help', location.origin);
if (help && help.origin === location.origin) {                              // same-origin only
  document.querySelector('#help').href = help.href;
}
```

## Framework escape hatches

React, Vue, Angular and Svelte escape text interpolation, so DOM XSS in single-page apps comes from the APIs that opt out of escaping and from code that reaches around the framework through refs or `ElementRef`. React 19.0 replaces `javascript:` URLs in `href` with one that throws when followed; it does not check other schemes, so validate URLs yourself. Angular sanitizes values bound to `innerHTML`. Vue's guide says never to use untrusted content as a template and never to mount Vue on server-rendered HTML that contains user content.

The component sanitizes before the escape hatch and links only to https URLs. Under Trusted Types, DOMPurify returns a `TrustedHTML` object and React hands `__html` to `innerHTML` unchanged (tested 19.2.7 and 19.3.0). React 19.3, released in September 2026, also stopped converting attribute values to strings, so `TrustedHTML` in an attribute such as `srcDoc` now works where 19.2.7 threw a `TypeError`.

```jsx
import DOMPurify from 'dompurify';

// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify
export function Comment({ html, website }) {
  // RETURN_TRUSTED_TYPE yields TrustedHTML; React hands __html to innerHTML unchanged.
  const clean = DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true });
  const site = URL.parse(website ?? '');
  return (
    <article>
      <div dangerouslySetInnerHTML={{ __html: clean }} />
      {site?.protocol === 'https:' && <a href={site.href}>Website</a>}
    </article>
  );
}
```

| Framework | Review these | Note |
| --- | --- | --- |
| React | `dangerouslySetInnerHTML`, `href` and `src` from data, refs that set `innerHTML` | `javascript:` URLs blocked since 19.0 |
| Vue | `v-html`, `:href` and `:src` from data, in-DOM templates | Never mount on pages that contain user content |
| Angular | `bypassSecurityTrustHtml` and its siblings, `ElementRef.nativeElement` | Trusted Types policies: `angular`, `angular#unsafe-bypass` |
| Svelte | `{@html}` | Only for sanitized or developer-controlled strings |

## Enforce Trusted Types so missed sinks fail closed

Trusted Types turns DOM XSS into a type error. With `require-trusted-types-for 'script'`, the browser rejects plain strings at HTML sinks, script sinks and script URL sinks, and blocks navigation to `javascript:` URLs. Only objects created by a policy pass, and the `trusted-types` directive lists the policy names the page may create; a second policy with the same name fails unless `'allow-duplicates'` is present. Review then covers a few policy functions instead of every `innerHTML` in the codebase. Chrome 154 also blocked `eval` and `new Function` on strings under enforcement.

Roll out in report-only mode first; the reports are your inventory of sinks. A policy named `default` receives every string that would otherwise throw, which helps with third-party code during migration, but a default policy that returns its input unchanged removes the protection. Check libraries: jQuery 3.x fails to initialize under enforcement, while jQuery 4.0 accepts `TrustedHTML` in `html()`. `Element.setHTML()`, in Chrome 146 and Firefox 148 but not in Safari on 11 October 2026, sanitizes as it inserts and needs no policy.

```js
// trusted-html.js: the only place the app turns a string into TrustedHTML.
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html dompurify
import DOMPurify from 'dompurify';

// DOMPurify parses through its own 'dompurify' policy, so the CSP must allow that name too.
const sanitize = (dirty) => DOMPurify.sanitize(dirty, { SANITIZE_NAMED_PROPS: true });

// Browsers without Trusted Types (Safari before 26) still get the sanitizer.
const policy = globalThis.trustedTypes?.createPolicy('app-html', { createHTML: sanitize })
  ?? { createHTML: sanitize };

export function setUserHTML(element, dirty) {
  element.innerHTML = policy.createHTML(dirty);  // a raw string here throws a TypeError
}
```

## postMessage: an origin check that isn't one

Any page that holds a reference to your window can post a message to it, including any site that frames your page or opens it in a popup. The browser reports the sender's origin in `event.origin`, and checking it is the receiver's job. Prefix and suffix checks fail: `https://partner.example.evil.net` starts with `https://partner.example`, and `endsWith('partner.example')` also accepts `https://notpartner.example`. Unescaped dots in a regular expression match any character. In the test, a page on `partner.example.evil.net` framed the app and ran script through the banner's `innerHTML`.

Sandboxed iframes and other documents with an opaque origin send the origin `'null'`, so never put that value on an allowlist. Checking the origin is not enough when the data then reaches a sink: a trusted partner with its own XSS bug becomes a path into your page.

```js
// Receiver on https://app.example.com
window.addEventListener('message', (event) => {
  // A prefix check: https://partner.example.evil.net passes it too.
  if (event.origin.indexOf('https://partner.example') !== 0) return;
  document.querySelector('#banner').innerHTML = event.data.html;  // and an HTML sink
});
```

## Check origin, source and shape before acting

The fixed receiver applies three checks: the exact origin string, the exact window it embedded, and a schema that allows one message type with a bounded integer. The action writes a style value, never HTML. In testing, a resize from the real widget was applied, while resize messages from a second partner frame, a sandboxed frame and an attacker page that framed the app were ignored. ASVS 5.0.0 requirement 3.5.5 asks for both the origin and the syntax checks.

The sender needs the same care. Name the receiving origin as `targetOrigin`, and the browser drops the message if the target window now holds a document from another origin. With `'*'`, the tested app delivered a token to `evil.example` after its partner frame navigated there. For sign-in popups, also check that the reply answers a request this window made, for example with a `state` value.

```js
// Receiver on https://app.example.com, which embeds the widget in <iframe id="partner-widget">
const PARTNER_ORIGIN = 'https://partner.example';
const frame = document.querySelector('#partner-widget');

window.addEventListener('message', (event) => {
  if (event.origin !== PARTNER_ORIGIN) return;        // exact match; never accept "null"
  if (event.source !== frame.contentWindow) return;   // only the frame we embedded
  const msg = event.data;
  if (msg === null || typeof msg !== 'object' || msg.type !== 'resize') return;
  if (!Number.isInteger(msg.height) || msg.height < 100 || msg.height > 2000) return;
  frame.style.height = `${msg.height}px`;             // one narrow action, no HTML
});

// Sending: name the receiving origin. With '*', whatever page the frame
// has navigated to receives the message.
function sendTheme(theme) {
  frame.contentWindow.postMessage({ type: 'theme', value: theme }, PARTNER_ORIGIN);
}
```

## DOM clobbering: markup that answers your lookups

Browsers expose elements with an `id`, and some elements with a `name`, as properties of `window` and `document`, and the document's named properties even take precedence over its built-in ones. An attacker who can inject only scriptless HTML, which most sanitizers allow, can therefore answer lookups that your code expects to return configuration or browser objects. CVE-2024-43788 in webpack and CVE-2024-47068 in Rollup worked this way: an injected `img` named `currentScript` replaced `document.currentScript`, and its `src` became the base URL for loading more script.

In the test, both lookups returned the attacker's values, and the type check fell back safely. Keep configuration in `const` bindings, never read settings from `window` or `document` properties, and check DOM objects with `instanceof` before trusting them. `Object.freeze` on a configuration object is a reasonable extra, but it is shallow and does not protect lookups on `window` or `document`. DOMPurify's defaults kept `appConfig` and `cdn`; `SANITIZE_NAMED_PROPS: true` prefixed them with `user-content-`.

```js
// Markup a sanitizer kept, because it has no script or event handlers:
//   <img name="currentScript" src="https://evil.example/">
//   <a id="appConfig"></a><a id="appConfig" name="cdn" href="https://evil.example/">

// Vulnerable: named elements answer these lookups.
const base = document.currentScript.src;        // "https://evil.example/"
const cdn = window.appConfig?.cdn || '/static';  // an <a> that stringifies to its href

// Fixed: check the type you got, and keep configuration in a const binding.
const current = document.currentScript;
const safeBase = current instanceof HTMLScriptElement ? current.src : location.href;
const config = Object.freeze({ cdn: '/static' });
```

## Client-side template injection in AngularJS

AngularJS 1.x compiles everything inside an `ng-app` element, including text the server rendered. HTML escaping does not help, because `{{ }}` contains no HTML-special characters: the escaped review below still ran in AngularJS 1.8.3. Version 1.6 removed the expression sandbox because developers kept treating it as a security boundary, which it never was; any expression that reaches the compiler can run arbitrary JavaScript. User input passed to `$eval`, `$parse`, `$compile` or an `orderBy` expression has the same effect.

AngularJS support ended in January 2022, so the lasting fix is migration. Until then, keep user content out of compiled regions or wrap it in `ng-non-bindable`, which AngularJS skips. Current frameworks state the same rule: Angular warns against building templates by concatenating user input, and Vue against using untrusted content as a template.

```html
<!-- Server-rendered page using AngularJS 1.x. The template engine HTML-escaped the review,
     but {{ }} is not HTML, so AngularJS still evaluates it as an expression. -->
<div ng-app>
  <p class="review">{{constructor.constructor(&#39;alert(document.domain)&#39;)()}}</p>

  <!-- Until you migrate: AngularJS does not compile inside ng-non-bindable. -->
  <p class="review" ng-non-bindable>{{constructor.constructor(&#39;alert(document.domain)&#39;)()}}</p>
</div>
```

## Find sinks in code and prove fixes in a browser

Start from sinks: search for the APIs in the table and each framework's escape hatch, and let Mozilla's `eslint-plugin-no-unsanitized` or CodeQL's `js/xss` query flag new ones in CI. Trace each argument back to a source. Then test in a real browser, because only its parser decides what runs. The test below fails on the vulnerable `welcome.js` and passes on the fixed one. It asserts that no element was created as well as that no script ran, which is stronger than waiting for an alert.

```js
// welcome.test.mjs: run with BASE_URL and CHROME_PATH set, e.g. node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer-core';

const PAYLOAD = '<img src=x onerror="window.__xss=1">';

test('the greeting renders the URL fragment as text', async (t) => {
  const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH });
  t.after(() => browser.close());
  const page = await browser.newPage();
  await page.goto(`${process.env.BASE_URL}/welcome#${encodeURIComponent(PAYLOAD)}`,
    { waitUntil: 'networkidle0' });

  assert.equal(await page.$('#greeting img'), null);  // no element was created
  assert.equal(await page.$eval('#greeting', (el) => el.textContent), `Welcome back, ${PAYLOAD}`);
  assert.equal(await page.evaluate(() => window.__xss), undefined);  // and nothing ran
});
```

## Pitfalls

Most surviving DOM XSS sits next to a control that checks the wrong thing.

- Sanitizing and then changing the result: concatenation or a regex replace after DOMPurify can recreate active markup.
- Sanitizing for HTML and then using the value as a URL, a script or a template; each sink needs its own control.
- Leaving `'dompurify'` out of the `trusted-types` list: DOMPurify then cannot parse, returns an empty string and looks like a rendering bug.
- Checking `event.origin` and then forwarding `event.data` to `innerHTML`, `eval` or `location`.
- Running jQuery older than 3.5.0. Its `html()` rewrote markup before parsing (CVE-2020-11022, CVE-2020-11023), and CISA added CVE-2020-11023 to its Known Exploited Vulnerabilities catalog in January 2025.
- Counting on a strict CSP alone. It stops injected inline handlers, but not a gadget in your own trusted script, such as code that sets `script.src` from data under `'strict-dynamic'`.

## Guided practice

Review the browser code of a support portal before release. Decide which lines are exploitable, then specify fixes and tests.

### Supplied material

```text
Origin: https://support.example.test. Customers write ticket subjects; the help widget is served from https://help.example.test and framed at #help-widget.
Libraries: jQuery 3.4.1 on every page; AngularJS 1.8.3 on /kb pages. S1 to S4 run on every page.
S1  document.title = 'Ticket ' + decodeURIComponent(location.hash.slice(1));
S2  const t = await (await fetch('/api/tickets/42')).json(); $('#subject').html(t.subject);
S3  window.addEventListener('message', (e) => {
      if (/^https:\/\/help.example.test$/.test(e.origin)) location.href = e.data.next;
    });
S4  const cfg = window.supportConfig || { uploadUrl: '/upload' };
    fetch(cfg.uploadUrl, { method: 'POST', body: formData });
S5  /kb/article (server-rendered, HTML-escaped): <div ng-app> ... <p>{{ customer comment }}</p> ... </div>
Customer comments on /kb may contain HTML that the server sanitizes with DOMPurify defaults before rendering.
```

### 1. Classify each line

For S1 to S5, name the source and the sink, and decide whether an attacker can run script or steal data.

Worked answer: S1 is safe: `document.title` is a text sink. S2 is stored DOM XSS: a customer subject reaches jQuery `html()`. S3 accepts origins such as `https://help-example.test` because the dots are unescaped, and `location.href` accepts `javascript:` URLs, so it is XSS as well as an open redirect. S4 is DOM clobbering: a comment containing elements with `id` `supportConfig` and a nested `name` `uploadUrl` redirects the upload. S5 is client-side template injection; HTML escaping does not neutralize `{{ }}`.

### 2. Fix the message handler

Rewrite S3 so that only the help widget can trigger navigation, and only within the portal.

Worked answer: Compare `e.origin === 'https://help.example.test'`, require `e.source` to be the widget frame's `contentWindow`, require a known message type, then pass the path through `URL.parse(path, location.origin)` and navigate only if the result's origin equals `location.origin`. A fixed map from message values to routes is better still.

### 3. Fix S2 and S4

Choose the safe API for the subject, and remove the clobbering gadget.

Worked answer: Use `$('#subject').text(t.subject)`, or `textContent`. Define the upload URL as a const in the module, never read it from `window`, and sanitize comments with `SANITIZE_NAMED_PROPS: true` so user `id` and `name` values gain the `user-content-` prefix.

### 4. Plan Trusted Types

List the steps to reach an enforced `require-trusted-types-for 'script'` policy, and what breaks first.

Worked answer: Deploy it in report-only mode and fix each reported sink. jQuery 3.4.1 fails to initialize under enforcement, so upgrade to jQuery 4.0, which also removes the CVE-2020-11022 and CVE-2020-11023 exposure. Create one policy that wraps DOMPurify and allow both names: `trusted-types app-html dompurify`. Treat the AngularJS `/kb` pages separately: `ng-non-bindable` around comments now, then migrate.

### 5. Write the regression tests

Specify four browser tests that would have caught these bugs.

Worked answer: A fragment payload on the ticket page creates no element. A subject of `<img src=x onerror=...>` renders as text. Messages from `https://help-example.test`, from a sandboxed frame and from the right origin with a `javascript:` URL all leave `location` unchanged. A `/kb` comment containing `{{7*7}}` renders literally, and one containing clobbering markup leaves the upload URL unchanged.

Deliverable: A table of S1 to S5 with source, sink, verdict and fix, plus the four test cases.

Scope: Paper exercise: the fixture is complete. You can reproduce each finding with a local server and a current Chromium-based browser.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [W3C Trusted Types (Working Draft, 7 October 2026)](https://www.w3.org/TR/trusted-types/)
- [MDN: Trusted Types API, injection sinks and the default policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API)
- [HTML Standard: cross-document messaging, security for authors](https://html.spec.whatwg.org/multipage/web-messaging.html#authors)
- [OWASP DOM based XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html)
- [OWASP DOM Clobbering Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/DOM_Clobbering_Prevention_Cheat_Sheet.html)
- [GitHub advisory GHSA-4vvj-4cpr-p986: webpack DOM clobbering gadget (CVE-2024-43788)](https://github.com/advisories/GHSA-4vvj-4cpr-p986)
- [AngularJS Developer Guide: Security (templates, expressions and sandbox removal)](https://docs.angularjs.org/guide/security)
- [Angular: Security best practices (sanitization and Trusted Types)](https://angular.dev/best-practices/security)
- [OWASP ASVS 5.0.0, V3 Web Frontend Security (3.2.2, 3.2.3, 3.5.5)](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x12-V3-Web-Frontend-Security.md). Version 5.0.0.
- [jQuery 4.0.0 release notes (Trusted Types support)](https://blog.jquery.com/2026/01/17/jquery-4-0-0/)