Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 6 – A05:2025 Injection

DOM XSS, Trusted Types and HTML sanitizers

Find DOM XSS sources and sinks, replace unsafe sinks with safe calls, enforce Trusted Types, filter rich text and check message senders.

  • Advanced
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 6 of 25 minutes free
Free: DOM XSS is a source reaching a sink, and three habits close it. With a pass: the other 7 sections and 3 interview answers, about 19 minutes.

What you will learn

  • Find DOM XSS sources and sinks in client code with a scan and a review
  • Replace unsafe sinks with safe calls, and check addresses with the URL parser before they reach a navigation sink
  • Configure Trusted Types with a Content Security Policy so that unsafe sinks fail closed, and roll it out in report-only mode first
  • Choose between text, a small markup and a maintained HTML sanitizer for rich notes
  • Protect postMessage handlers from untrusted senders, and avoid DOM clobbering

Before you start

What you’ll learn · Before you start

What you will learn

  • Find DOM XSS sources and sinks in client code with a scan and a review
  • Replace unsafe sinks with safe calls, and check addresses with the URL parser before they reach a navigation sink
  • Configure Trusted Types with a Content Security Policy so that unsafe sinks fail closed, and roll it out in report-only mode first
  • Choose between text, a small markup and a maintained HTML sanitizer for rich notes
  • Protect postMessage handlers from untrusted senders, and avoid DOM clobbering

Before you start

On this page

Cross-site scripting is first in MITRE’s 2025 CWE Top 25, and one kind of it can run without your server ever seeing the text: in DOM XSS: Cross-site scripting in which the page's own script reads text from a source such as the address or a message and writes it into the page through a sink such as innerHTML, so the server never sees it. the page’s own script takes text from the address, a message or storage and writes it into the page with a call such as innerHTML. Three habits close it. Write text with calls that cannot make markup. Make the browser refuse plain strings at the risky calls with Trusted Types: A browser feature that makes risky sinks accept only values made by a named policy, such as TrustedHTML, and throw a TypeError for a plain string. A Content Security Policy directive switches it on.. And run the few pieces of HTML that people must supply through HTML sanitizers that you did not write.

You will find the sources and sinks of a small page script, rewrite the sinks, test a Trusted Types policy and its rollout, choose between text, a small markup and a sanitizer for rich notes, and check who sends your page a message.

DOM XSS is a source reaching a sink, and three habits close it

A notes viewer shows a title from the query string and a note from the part of the address after the # sign. Both are sources, places where text can enter the page’s script from outside its own code. The address, document.referrer, window.name, messages from other windows, web storage and responses from the server are the usual ones. A sink: A place in code where text is handed to an interpreter: a query, a page, a command, a header, an export, a log line or a template. Each sink needs a safe API, parameters or that interpreter's own encoding. More in “Injection: keep data out of code” is a call that turns text into something the browser acts on: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, a timer given a string, or an address assigned to href, src or location. DOM XSS is cross-site scripting where the browser’s own script, not the server, writes the harmful text into the page. The server never sees the part after the # sign, so no filter or log on the server can catch text that arrives there. The program below is a first sink inventory for browser code. It notes which variables hold text from a source and reports each place where that text reaches a sink:

A page script scanned for sources that reach sinks JavaScript · scan/sink_scan.mjs
// A first sink inventory for browser code. It reads a page script line by line, notes which variables hold text from a
// source (the address, a message, storage), and reports each place where such text reaches a sink. Real tools follow
// the data through functions and files; this one matches names, which is enough to show the shape of the problem.
const SOURCES = [
  [/location\.(?:hash|search|href)/, 'the address'],
  [/params\.get\(/, 'the query string'],
  [/document\.referrer/, 'the referrer'],
  [/window\.name/, 'window.name'],
  [/event\.data/, 'a message'],
  [/(?:local|session)Storage\.getItem\(/, 'web storage'],
];
const SINKS = [
  [/\.(?:innerHTML|outerHTML)\s*=/, 'HTML', 'textContent, or createElement and append'],
  [/\.insertAdjacentHTML\(/, 'HTML', 'insertAdjacentText, or createElement'],
  [/document\.write(?:ln)?\(/, 'HTML', 'DOM methods'],
  [/\b(?:eval|new Function)\(/, 'code', 'JSON.parse, or a function'],
  [/set(?:Timeout|Interval)\(\s*['"`]/, 'code', 'a function, not a string'],
  [/\.(?:href|src|action)\s*=/, 'URL', 'an allowlist of schemes'],
];

function scan(name, lines) {
  const tainted = new Map(); // variable -> the source it came from
  const found = [];
  const reviews = [];
  lines.forEach((line, i) => {
    const source = SOURCES.find(([re]) => re.test(line))?.[1];
    const declared = /^(?:const|let|var)\s+(\w+)\s*=/.exec(line)?.[1];
    const viaVariable = [...tainted].find(([v]) => new RegExp(`\\b${v}\\b`).test(line.replace(/^(?:const|let|var)\s+\w+\s*=/, '')))?.[1];
    const origin = source ?? viaVariable;
    if (declared && origin) tainted.set(declared, origin);
    const sink = SINKS.find(([re]) => re.test(line));
    if (!sink) return;
    const wrapped = /\bsafe\w*\(/.test(line);
    const kind = { HTML: 'an HTML', code: 'a code', URL: 'a URL' }[sink[1]];
    if (origin && !wrapped) found.push(`line ${i + 1}: ${origin} reaches ${kind} sink; use ${sink[2]}`);
    else if (wrapped) reviews.push(`line ${i + 1}: ${kind} sink behind a checking function; read the function`);
  });
  console.log(`${name}: ${found.length} flow${found.length === 1 ? '' : 's'} from a source to a sink, ${reviews.length} to review`);
  for (const text of [...found, ...reviews]) console.log(`  ${text}`);
}

const weak = [
  "const params = new URLSearchParams(location.search);",
  "const title = params.get('title');",
  "const note = decodeURIComponent(location.hash.slice(1));",
  "heading.innerHTML = '<h2>' + title + '</h2>';",
  "body.insertAdjacentHTML('beforeend', note);",
  "link.href = params.get('next');",
  "setTimeout('refresh(' + params.get('id') + ')', 1000);",
  "window.addEventListener('message', (event) => { preview.innerHTML = event.data; });",
  "status.textContent = 'Loaded ' + title;",
];
const fixed = [
  "const params = new URLSearchParams(location.search);",
  "const title = params.get('title');",
  "const note = decodeURIComponent(location.hash.slice(1));",
  "heading.textContent = title;",
  "body.append(note);",
  "link.href = safeUrl(params.get('next'));",
  "setTimeout(() => refresh(Number(params.get('id'))), 1000);",
  "window.addEventListener('message', (event) => { if (event.origin === APP) preview.textContent = String(event.data); });",
  "status.textContent = 'Loaded ' + title;",
];
scan('weak page script', weak);
scan('fixed page script', fixed);

Output

weak page script: 5 flows from a source to a sink, 0 to review
  line 4: the query string reaches an HTML sink; use textContent, or createElement and append
  line 5: the address reaches an HTML sink; use insertAdjacentText, or createElement
  line 6: the query string reaches a URL sink; use an allowlist of schemes
  line 7: the query string reaches a code sink; use a function, not a string
  line 8: a message reaches an HTML sink; use textContent, or createElement and append
fixed page script: 0 flows from a source to a sink, 1 to review
  line 6: a URL sink behind a checking function; read the function

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sink_scan.mjs

The weak script has five flows, and each line of the report names the call that does the same job safely. The fixed script has none, and one sink behind a checking function for a person to read. Real analysers follow data through functions and files; this one only matches names. Three habits close what a scan finds. The first is to write text with textContent, which makes a text node and no markup. The second is Trusted Types, a browser feature that makes the risky sinks accept only values made by a named policy and throw on a plain string. The third is for the case where people must supply formatting. HTML sanitizer: A library or browser feature that parses HTML and rebuilds it from an allowlist of tags and attributes, so formatting can stay while script is removed. It filters markup and does not sandbox script. parse the markup and rebuild it from an allowlist of tags and attributes. Use a maintained one, because a sanitizer is the kind of code that new browser behaviour breaks. The first habit is the easiest to see, so step through one note that travels from the address to the page, first through innerHTML and then through textContent.

Interactive · message by message One note from the address to the page, written two ways Step through innerHTML first, where the browser's parser reads the note, and then textContent, where it does not.
One note from the address to the page, written two ways, step 6 of 6. Messages so far: The address to Page script: the hash text; Page script to The browser: innerHTML = note; Page script to The browser: textContent = note.The addressafter the # signPage scriptthe notes viewerThe browsermakes a text nodethe hash textinnerHTML = notetextContent = note

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A note arrives. The address ends with #<b>hi</b>. The part after the # sign is never sent to the server, so no server-side filter or log can see it. It is one ordinary string.
  2. The script reads it. The viewer reads location.hash and removes the first character. It now holds the text <b>hi</b> in a variable called note.
  3. innerHTML hands it over. The script assigns the note to body.innerHTML. That sink takes a string, gives it to the browser's HTML parser and puts what the parser builds into the page.
  4. The parser builds markup. The parser reads <b>hi</b> as an element, so the reader sees bold text. Bold is harmless, but the same path builds any element a person types, a script element included.
  5. textContent instead. The fixed script assigns the same note to body.textContent. That sink makes one text node holding the characters, and the parser is not asked to read anything.
  6. The page shows what was typed. The reader sees the characters <b>hi</b> on the page, and no element was made. The text crossed from a source to a sink and stayed text.

In the second run the note arrived as characters and no element was made, which is all the first habit has to do.

Checkpoint

Check: Sources and sinks

  1. Question 1 of 2 Which line of the notes viewer's script is a sink?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: body.insertAdjacentHTML('beforeend', note)

    A source is where outside text enters the script, and a sink is a call that acts on text as markup, code or an address. The danger is a path from the first to the second with no check between them.

  2. Question 2 of 2 Why can body.textContent = note not turn the note into markup, when body.innerHTML = note can?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: textContent stores the characters in a text node, so the HTML parser never reads the string

    The two sinks differ in who reads the string. innerHTML hands it to the HTML parser, and textContent stores it as text. The rewrite removes the reader instead of filtering what it reads.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is DOM XSS, and why can no server filter catch it?

    The page's own script takes text from a source (the address, document.referrer, window.name, messages, web storage, server responses) and writes it through a sink such as innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, a timer given a string, or an address assigned to href, src or location. The server never sees the part after the # sign, so no filter or log there can catch it.

  2. What are the three habits that close DOM XSS?

    Write text with calls that cannot make markup (textContent makes a text node and no markup); make the browser refuse plain strings at the risky calls with Trusted Types, which accept only values made by a named policy; and pass the few pieces of HTML that people must supply through a maintained HTML sanitizer that rebuilds the markup from an allowlist of tags and attributes.

  3. What are a source, a sink and a sink inventory?

    A source is a place where text can enter the page's script from outside its own code; a sink is a call that turns text into something the browser acts on. A sink inventory notes which variables hold text from a source and reports each place it reaches a sink, naming the safe call for the same job. A name-matching scan is only a first inventory, since real analysers follow data through functions and files.

  4. Why use a maintained HTML sanitizer and not write your own?

    A sanitizer must parse the markup the way the browser does and rebuild it from an allowlist, and it is the kind of code that new browser behaviour breaks. Use a maintained one that you did not write.

Rewrite each unsafe sink with the safe call that does the same job Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Trusted Types make the browser refuse a plain string at the risky sinks
  • Rich text needs a sanitizer you did not write, or no HTML at all
  • Check who sent a message and what your script reads from the page
  • Practise: make a notes viewer safe
    • Four functions of a notes viewer, made safe
  • Interview questions
    • What is DOM-based XSS, and how does it differ from stored and reflected XSS?
    • A single-page app has 400 uses of innerHTML and three third-party widgets. Security wants Trusted Types enforced next quarter. Lay out the rollout, and say what could go wrong.
    • A support tool previews customer emails, which are HTML, in the agent's page. It cleans each body with a regular expression on the server and then assigns it to innerHTML. How do you assess it, and what do you change?
    • A checkout page embeds a payment widget in an iframe and exchanges messages with it. A review finds that the handler tests whether event.origin contains the widget's host name. What do you change, and what else do you check?
  • Key takeaways

1 diagram · 4 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 37 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Terms in this lesson

DOM clobbering
Using elements whose id or name matches a global that the script reads, so that the document supplies the value instead of the script. Scoped variables and type checks stop it.
DOM XSS
Cross-site scripting in which the page's own script reads text from a source such as the address or a message and writes it into the page through a sink such as innerHTML, so the server never sees it.
HTML sanitizer
A library or browser feature that parses HTML and rebuilds it from an allowlist of tags and attributes, so formatting can stay while script is removed. It filters markup and does not sandbox script.
Trusted Types
A browser feature that makes risky sinks accept only values made by a named policy, such as TrustedHTML, and throw a TypeError for a plain string. A Content Security Policy directive switches it on.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.