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.
Table of Contents
- Where untrusted data enters extension pages
- Step-by-step: safe rendering habits
- 1. Use text APIs by default
- 2. Validate URLs before using them
- 3. Sanitise rich content when you must render HTML
- 4. Let frameworks escape for you — and know their escape hatches
- 5. Understand what the CSP protects — and what it doesn’t
- 6. Treat notifications and other surfaces the same way
- 7. Add a lint rule
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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
innerHTMLwith 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-htmlanddangerouslySetInnerHTML.
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
innerHTMLwith dynamic data during review. - Safari: enforces the extension CSP; the same rendering discipline applies.
Verification
- Save an item whose title is
<b>bold</b><img src=x onerror=alert(1)>and confirm the popup shows the literal text. - Save an item with URL
javascript:alert(1)and confirm the link is inert or replaced. - Run the lint rule over the codebase and confirm no unsanitised sinks remain.
- 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.
Related
- Sanitising untrusted page data in an extension — sanitiser usage in depth.
- Treating content script messages as untrusted — the message channel.
- Fixing CSP violations in extension pages — reading the console errors.
- Extension security and CSP hardening — the parent topic.