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.
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
- A05:2025 Injection (OWASP Foundation)
- DOM based XSS Prevention Cheat Sheet (OWASP Foundation)
- Cross Site Scripting Prevention Cheat Sheet (OWASP Foundation)
- DOM Clobbering Prevention Cheat Sheet (OWASP Foundation)
- Trusted Types API (MDN Web Docs (Mozilla))
- Trusted Types (W3C Working Draft) (W3C)
- HTML Sanitizer API (MDN Web Docs (Mozilla))
- DOMPurify: README (Cure53)
- Window: postMessage() method (MDN Web Docs (Mozilla))
- ASVS 5.0.0, V1 Encoding and Sanitization (requirement 1.3.1) (OWASP Foundation)
- ASVS 5.0.0, V3 Web Frontend Security (requirements 3.2.2, 3.2.3 and 3.5.5) (OWASP Foundation)
- URL Standard (WHATWG)
- MDN browser-compat-data (Trusted Types, Sanitizer and Content-Security-Policy directives) (MDN Web Docs (Mozilla))
- 2025 CWE Top 25 Most Dangerous Software Weaknesses (MITRE)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress