Preventing XSS in Extension Pages

Stop cross-site scripting in MV3 popups, options pages and side panels: where untrusted data comes from, textContent over innerHTML, DOMPurify for rich content, safe URLs, framework escaping and what the CSP does and doesn't catch.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The popup shows the titles of saved pages. One saved page had the title <img src=x onerror="…">, and the popup rendered it with innerHTML. The MV3 content security policy blocked the inline handler, so nothing ran — this time. Extension pages are attractive XSS targets because a script running there has every permission the extension holds: it could read stored tokens, call chrome.cookies on every granted host, or message content scripts on every tab. The CSP is a strong backstop, but it does not stop every injection — markup that changes links, forms, or styles needs no script at all. This guide covers the habits that prevent XSS in the first place. It belongs to extension security and CSP hardening.

Where untrusted data enters extension pages

Most extension data originates on web pages the extension does not control: page titles and URLs, selected text, scraped content, metadata, notification text built from page data, and messages from content scripts running in those pages. Data from your own backend is untrusted too if any of it was supplied by other users — shared notes, comments, usernames. Even the user’s own synced settings can carry payloads if an attacker ever had access to their account. All of it eventually flows into a popup, options page, side panel or notification. The rule is simple: treat every string that did not come from your package as text, and convert it to markup only through a sanitiser you trust.

Untrusted data flowing into an extension pagePage titles, selections and scraped content arrive through content scripts and storage; backend data from other users arrives through the worker; all of it is rendered in the popup, where it must be treated as text or sanitised.Web pagestitles, selection, DOMContent scriptsmessagesYour backendother users' contentstored, then renderedchrome.storagesaved itemsPopup / optionsrendertextContent / sanitiserthe safe sinks
Every arrow into the page is a place where text could become markup.

Step-by-step: safe rendering habits

1. Use text APIs by default

1// popup.js
2function renderItem(item) {
3  const li = document.createElement("li");
4  const a = document.createElement("a");
5  a.textContent = item.title;                    // never innerHTML for data
6  a.href = safeUrl(item.url) ?? "#";
7  li.append(a);
8  return li;
9}

Execution context: any extension page. textContent, createElement, setAttribute with fixed attribute names, and append with strings never parse markup, so data cannot become elements or handlers. This covers the vast majority of extension UI — lists of titles, counts, labels, status text. Templates with innerHTML are fine only when every interpolated value is a constant from your own code.

2. Validate URLs before using them

1export function safeUrl(raw) {
2  try {
3    const u = new URL(raw);
4    return ["http:", "https:"].includes(u.protocol) ? u.href : null;
5  } catch {
6    return null;
7  }
8}

Execution context: a shared module. A javascript: URL in an href or location.assign runs code when clicked — and the MV3 CSP does block javascript: URLs in extension pages, but data: URLs that open HTML documents and links to phishing pages are still possible. Allow only the schemes the feature needs. Use the same check before chrome.tabs.create({ url }), which can otherwise open arbitrary schemes.

DOM sinks and whether data is safe in themCommon DOM APIs classified by whether untrusted strings passed to them are rendered as text, need URL validation, need sanitising, or must never receive data.SinkUntrusted string isUse for data?textContent / append(str)TextYessetAttribute('title', s)TextYesa.href / tabs.create urlURLAfter scheme checkinnerHTML / insertAdjacentHTMLMarkupOnly sanitisedsetAttribute('on…', s)CodeNever
Use the left column by default; reach right only with a sanitiser.

3. Sanitise rich content when you must render HTML

 1import DOMPurify from "dompurify";
 2
 3export function renderRich(container, html) {
 4  const clean = DOMPurify.sanitize(html, {
 5    ALLOWED_TAGS: ["p", "a", "strong", "em", "ul", "ol", "li", "code", "pre", "blockquote", "h2", "h3", "img"],
 6    ALLOWED_ATTR: ["href", "src", "alt", "title"],
 7    ALLOWED_URI_REGEXP: /^(?:https?:|#)/i,
 8    RETURN_DOM_FRAGMENT: true,
 9  });
10  container.replaceChildren(clean);
11}

Execution context: an extension page rendering saved article excerpts or Markdown output. A well-maintained sanitiser with an allow-list of tags and attributes removes scripts, event handlers, style and dangerous URLs. Returning a DOM fragment avoids a second parse of the cleaned string. Sanitise at render time, not only at save time, so a later bug in the saving path cannot store a payload that renders unsanitised. See sanitising untrusted page data in an extension.

4. Let frameworks escape for you — and know their escape hatches

1// React escapes by default
2<span>{item.title}</span>
3
4// The escape hatch: only with sanitised input
5<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.excerpt) }} />

Execution context: an extension page built with a framework. React’s JSX, Vue’s {{ }} and Svelte’s {} escape interpolated strings. Their raw-HTML features — dangerouslySetInnerHTML, v-html, {@html} — bypass escaping and must only receive sanitised content. Grep for those names in code review; they are where framework apps get XSS.

A malicious title meets two renderersA page title containing an image tag with an error handler is saved; the innerHTML renderer creates the element and the CSP blocks the handler but the element still appears; the textContent renderer shows the title as literal text.Saved iteminnerHTML popuptextContent popuptitle = '<img src=x onerror=…>'element created; handler blocked by CSPsame titleshown as litera…
The CSP stops the script; only text rendering stops the markup.

5. Understand what the CSP protects — and what it doesn’t

1{ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; base-uri 'none'; form-action 'none'" } }

Execution context: the manifest. The MV3 minimum policy blocks inline scripts, javascript: URLs, eval and remote scripts, which stops most script-execution XSS. It does not stop injected <a> elements pointing to phishing pages, <form> elements that post data elsewhere, CSS that hides or overlays real controls, or <img> beacons leaking data through URLs. Adding base-uri 'none' and form-action 'none' closes two of those gaps; text rendering closes the rest. See writing a strict content security policy for MV3.

6. Treat notifications and other surfaces the same way

1chrome.notifications.create({
2  type: "basic", iconUrl: "icons/128.png",
3  title: "Saved",
4  message: truncate(stripControls(item.title), 120),     // plain text, length-limited
5});

Execution context: the service worker. Notifications render text, not HTML, but very long or control-character-laden strings can spoof content or break layout. Context menu titles, badge text and omnibox suggestions have their own escaping rules — the omnibox uses XML markup that must be escaped, as covered in escaping XML in omnibox descriptions.

7. Add a lint rule

1// .eslintrc
2{ "rules": { "no-unsanitized/property": "error", "no-unsanitized/method": "error" } }

Execution context: the build. The eslint-plugin-no-unsanitized rules flag assignments to innerHTML, outerHTML and calls like insertAdjacentHTML unless the value comes from an approved sanitiser. Turning XSS-prone patterns into lint errors stops regressions in review.

Common mistakes

  • innerHTML with template literals containing data. The most common extension XSS.
  • Trusting the CSP to catch everything. Markup injection needs no script.
  • Unvalidated URLs in links and tabs.create. Allow only expected schemes.
  • Sanitising only on save. Sanitise on render.
  • Framework escape hatches with raw data. Review every v-html and dangerouslySetInnerHTML.

Cross-browser variation

  • Chrome / Edge: MV3 CSP minimum enforced on extension pages; Trusted Types available as an extra layer.
  • Firefox: same CSP minimum; AMO reviewers flag innerHTML with dynamic data during review.
  • Safari: enforces the extension CSP; the same rendering discipline applies.

Verification

  1. Save an item whose title is <b>bold</b><img src=x onerror=alert(1)> and confirm the popup shows the literal text.
  2. Save an item with URL javascript:alert(1) and confirm the link is inert or replaced.
  3. Run the lint rule over the codebase and confirm no unsanitised sinks remain.
  4. Check every page’s console for CSP violations during normal use — each one is a sink worth fixing.

FAQ

Is DOMParser safe for untrusted HTML?

Parsing with DOMParser does not execute scripts, but inserting the parsed nodes into a live document reintroduces risk. Sanitise before insertion.

Are Shadow DOM or iframes a substitute?

They isolate styles, not script execution. A sandboxed iframe with no allow-scripts can render untrusted HTML more safely, at the cost of complexity.

Does Trusted Types help?

Yes — it makes unsanitised sink usage throw at runtime. See enforcing Trusted Types in extension pages.

Do the same rules apply to the service worker?

The worker has no DOM, so classic XSS does not apply, but injection still does: never build URLs, storage keys or executeScript arguments from untrusted strings without validation.

Other MV3 Architecture & Extension Lifecycle Resources