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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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.

How far a page-controlled string can travelFour layers showing data flowing from the page DOM through the content script and message channel into the service worker and privileged extension pages.Page DOMattacker-controlled on a hostile siteassume every string is craftedContent scriptisolated world, extension privilegessanitise here, at the sourceMessage channelstructured clone, no validationshape is not checked for youWorker + extension pagesstorage, tabs, host permissionsanything that lands here is trusted by your code
The boundary that matters is the second one — after it, the data is being handled by code with your extension's permissions.

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.

One crafted page title, end to endA hostile title is read by the content script, passed unvalidated through the message channel, stored, and finally rendered with innerHTML in the popup where it executes.document.titleattacker-controlledContent script reads itno validationsendMessage({title})structured clonenothing has checked it yetWorker stores itstorage.localPopup reads itprivileged documentinnerHTML renders itexecutes with extension rights
Four hops, and only the last one looks like a bug — which is why validation belongs at the first.

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.

Sinks, and what to use insteadSix DOM sinks that accept page-controlled data, the risk each carries inside a privileged extension page, and the safe alternative.SinkRisk in an extension pageUse insteadinnerHTML / outerHTMLScript execution with extension privilegetextContent / replaceChildreninsertAdjacentHTMLSame as innerHTMLcreateElement + appendsetAttribute('href')javascript: URLsValidate the protocolsetAttribute('src')Remote load, trackingPackaged assets onlyelement.style.cssTexturl() exfiltrationSet individual propertiesnew Function / evalBlocked by CSP alreadySandbox page if truly needed
The right-hand column is the whole of the practice — none of these alternatives are slower or longer to write.

Cross-browser variation

  • Chrome / Edge: Trusted Types and Element.setHTML are 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 textContent habit is portable and is what carries the load here.
  • Safari: likewise lacks both. Safari’s extension pages enforce the MV3 CSP, so eval is still unavailable, but nothing stops an innerHTML assignment.
  • 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

  1. 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.
  2. 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.

  1. Turn on require-trusted-types-for 'script' in a development build and exercise every surface; each violation is a real sink.
  2. 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.

Other MV3 Architecture & Extension Lifecycle Resources