Sanitising Untrusted Page Data in an Extension
Treat everything a content script reads as hostile — safe DOM construction, the Sanitizer API, trusted types, and why extension privilege makes page XSS far more serious.
Table of Contents
A content script reads a page title and the popup renders it with innerHTML. On a normal website that is a cross-site scripting bug confined to one origin. In an extension, the script that just ran holds your extension’s privileges — chrome.storage, chrome.tabs, every host permission the user granted — and it is running in a context the page cannot normally reach at all. The same mistake is categorically worse here. This guide is part of extension security and CSP hardening.
Why extension privilege changes the calculus
The extension’s CSP blocks eval and remote scripts, which stops a large class of injection — but it does not stop innerHTML building an <img onerror> in your own privileged page, and it does not stop a content script being tricked into sending attacker-chosen data to a worker that acts on it.
Step-by-step
1. Never build markup from page data
1// Dangerous: one crafted title is an injection in a privileged document.
2list.innerHTML = items.map((i) => `<li>${i.title}</li>`).join("");
3
4// Safe: text nodes cannot become markup.
5list.replaceChildren(...items.map((i) => {
6 const li = document.createElement("li");
7 li.textContent = i.title;
8 return li;
9}));
Execution context: an extension page such as the popup, under the extension’s own CSP. textContent is the single most effective habit here — it is not a sanitiser, it is an escape from the parsing path entirely.
The same rule applies to attributes. element.setAttribute("href", pageUrl) will happily accept javascript:; validate the scheme before assigning.
1function safeHref(raw) {
2 try {
3 const u = new URL(raw, location.href);
4 return (u.protocol === "https:" || u.protocol === "http:") ? u.href : null;
5 } catch { return null; }
6}
Execution context: any extension page or content script. Parsing with URL and checking the protocol is more reliable than a regular expression, which will eventually meet a \tjavascript: or a percent-encoded colon.
2. Validate at the boundary, not at the point of use
A message handler that trusts its payload is one refactor away from a bug. Validate shape and content where the message arrives, once.
1// service worker
2handle("page:report", (msg, sender) => {
3 if (!sender.tab) throw new Error("not from a tab");
4 const title = typeof msg.title === "string" ? msg.title.slice(0, 200) : "";
5 const url = safeHref(msg.url ?? "");
6 if (!url) throw new Error("bad url");
7 return record({ tabId: sender.tab.id, title, url });
8});
Execution context: the service worker. sender.tab is populated by the browser and cannot be forged by the message itself, which makes it the one field in the envelope you can trust — everything the page influenced must be checked.
3. Use the Sanitizer API where you genuinely need markup
Some features legitimately need rich content — a preview of an article, a rendered snippet. The Sanitizer API removes scripting constructs while preserving structure.
1if ("setHTML" in Element.prototype) {
2 preview.setHTML(articleHtml, { sanitizer: new Sanitizer() });
3} else {
4 preview.textContent = stripToText(articleHtml); // conservative fallback
5}
Execution context: an extension page. setHTML is available in Chrome 105+ and behind a flag elsewhere at the time of writing, so the fallback is load-bearing rather than defensive — and it should degrade to plain text rather than to innerHTML.
4. Turn the rule on with Trusted Types
Trusted Types converts “we agreed not to use innerHTML with page data” into a policy the browser enforces. In an extension page it is opt-in through the CSP.
1{
2 "content_security_policy": {
3 "extension_pages": "script-src 'self'; object-src 'none'; require-trusted-types-for 'script'"
4 }
5}
Execution context: parsed at install and enforced on every extension page. With it on, an assignment to innerHTML throws unless the value came from a policy you defined — which means a future contributor cannot reintroduce the bug without noticing.
1const policy = trustedTypes.createPolicy("ext-html", {
2 createHTML: (s) => new Sanitizer().sanitizeFor("div", s).innerHTML,
3});
4preview.innerHTML = policy.createHTML(articleHtml);
Execution context: an extension page. Naming the policy makes every place that produces markup greppable, which is most of the value — there should be one policy and one call site.
5. Be careful what you put back into the page
Injection runs both ways. A content script that writes extension data into the page hands it to every script on that page, including the site’s own analytics.
1// Leaks the user's token to the page and anything running in it.
2document.body.dataset.extToken = token;
3
4// Keep privileged values in the isolated world, and pass only what the page needs.
5window.postMessage({ type: "ext:ready" }, location.origin);
Execution context: the content script’s isolated world. postMessage with an explicit target origin rather than "*" avoids handing the message to a cross-origin frame that happens to be listening.
Cross-browser variation
- Chrome / Edge: Trusted Types and
Element.setHTMLare both available;require-trusted-types-for 'script'is accepted in the extension CSP. Violations are reported in the page’s own console. - Firefox: no Trusted Types enforcement and no Sanitizer API at the time of writing, so the discipline has to be maintained by code review and linting rather than by the platform. The
textContenthabit is portable and is what carries the load here. - Safari: likewise lacks both. Safari’s extension pages enforce the MV3 CSP, so
evalis still unavailable, but nothing stops aninnerHTMLassignment. - All three: a content script’s isolated world protects your variables from the page, not your data flow. A string read from the DOM is page-controlled in every engine.
Verification
- Add a hostile fixture and confirm nothing executes. Set a page’s title to
<img src=x onerror="alert(1)">, open your popup, and confirm the title renders as literal text. - Confirm no dangerous sinks remain in the built bundle:
1grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|cssText" dist/ | grep -v "\.map:"
Execution context: your shell, against the built output rather than the source — a framework or a helper can reintroduce a sink that your own code does not contain.
- Turn on
require-trusted-types-for 'script'in a development build and exercise every surface; each violation is a real sink. - Confirm the message handler rejects a malformed payload rather than storing it.
FAQ
Is the isolated world not already a security boundary?
It protects your JavaScript objects from the page’s, which is valuable. It does nothing about data: anything your script reads from the DOM was written by the page, and anything you write into the DOM is readable by it.
Do I need this if my extension only runs on sites I control?
Host permissions are granted per origin, not per page, and a site you control can still serve user-generated content. Treat the DOM as untrusted regardless of whose site it is.
Is DOMPurify a reasonable dependency?
Yes, where you need markup and the Sanitizer API is unavailable. It must be bundled rather than loaded remotely, and it should be behind the same single policy function so there is one place to audit.
Related
- Protecting extension messages from web pages — validating who is talking to you.
- Writing a strict Content Security Policy for MV3 — the policy that enforces some of this.
- Best practices for content script isolation in MV3 — what the isolated world does and does not give you.
- Extension security and CSP hardening — the parent guide.