Content Script Security: Avoiding XSS and Injection Vulnerabilities in Extensions

Content scripts are privileged code running in an unprivileged environment. They have access to extension APIs the page’s JavaScript can’t touch, and they can communicate with the background service worker. But they run in the same browser tab as potentially hostile web content, and if they process that content carelessly, an attacker who controls a page your extension visits can exploit the script to steal data, exfiltrate credentials, or escalate to the extension’s background worker.

Content script security vulnerabilities are not hypothetical. Security researchers have found XSS and injection vulnerabilities in major extensions including password managers and ad blockers. The attack surface is real.

The Isolated World Misconception

Content scripts run in an “isolated world” — their JavaScript scope is separate from the page’s. A content script can access document and window, but the page’s JavaScript cannot read the content script’s variables. This is a meaningful security boundary. It’s also frequently misunderstood as more protective than it is.

The isolated world protects the content script’s JavaScript scope. It does not protect the DOM. Content scripts and page scripts share the same DOM. If a content script writes attacker-controlled content into the DOM using innerHTML, document.write, or insertAdjacentHTML, the resulting DOM is visible to and executable by the page’s JavaScript. An XSS payload injected via a content script executes in the page’s world, which may have access to the user’s accounts on that page.

The Chrome extension security documentation explicitly lists this as a primary content script vulnerability.

innerHTML Is the Primary Attack Surface

The most common content script vulnerability: reading a value from the page (from an element’s text, a URL parameter, the page title) and writing it back into the DOM using innerHTML.

// VULNERABLE: title is attacker-controlled
const title = document.querySelector('h1').textContent;
myDiv.innerHTML = `<p>Page: ${title}</p>`;

If the page’s H1 contains <img src=x onerror=alert(1)> and your extension trusts that content, the onerror fires in the page context.

The fix: use textContent for text, createElement + appendChild for structure, or a sanitizer library if you genuinely need to render markup.

// Safe: textContent treats the value as text, not markup
myDiv.textContent = document.querySelector('h1').textContent;

// Safe: element creation with no innerHTML
const p = document.createElement('p');
p.textContent = 'Page: ' + title;
myDiv.appendChild(p);

For extensions that genuinely need to render HTML (a password manager showing formatted vault entries, a reader mode that reformats article content), use DOMPurify to sanitize before setting innerHTML. DOMPurify is maintained by a security researcher (Mario Heiderich / Cure53) and is used by major password managers.

Content Security Policy for Extension Pages

Popup pages, options pages, and other extension pages have a default Content Security Policy that blocks inline scripts and eval(). This is good — it prevents XSS from executing in extension pages even if HTML injection is possible. The Chrome extension CSP documentation covers the rules.

Don’t loosen the CSP without a strong reason. Specifically:

  • Never add unsafe-inline to the script-src directive
  • Never add unsafe-eval — if you’re using eval(), restructure the code
  • Never allow external script sources in the extension page CSP

Communicating Safely with the Page Context

Sometimes an extension needs to communicate with the page’s JavaScript (not just read the DOM). The two safe mechanisms:

Custom events via the DOM:

// Content script: receive data from page via custom event
window.addEventListener('message', event => {
  // ALWAYS validate the origin
  if (event.source !== window) return;
  if (!event.data?.type?.startsWith('MY_EXTENSION_')) return;
  // Process message
});

// Page context: send to content script
window.postMessage({ type: 'MY_EXTENSION_REQUEST', data: 'value' }, '*');

DOM attributes as a channel:

// Content script reads a DOM attribute the page set
const value = document.getElementById('extension-data')?.dataset.value;

In both cases, the content script must validate and sanitize data received from the page. The page is not trusted — it may be attacker-controlled.

Never use window.postMessage to send sensitive data like authentication tokens from the background worker through the content script to the page. The page can observe the message event.

Injection into the Page’s Script Context

Content scripts cannot directly access the page’s JavaScript scope. To run code in the page’s JavaScript context (with access to the page’s variables), inject a script element:

const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
document.head.appendChild(script);

This runs in the page’s world, not the extension’s. Code in injected.js has no access to chrome.* APIs. This is appropriate for modifying page behavior (patching JavaScript functions) but increases the attack surface — the injected code runs in the same context as the page and can be affected by the page’s JavaScript.

For reading data from the page context and sending it to the content script, the injected script can use window.postMessage back to the content script, which validates and processes it.

Overprivileged Permissions

Permissions are a security concern, not just a review concern. An extension with <all_urls> host permissions can inject content scripts into every page the user visits. If that extension has a content script XSS vulnerability, every page is a potential attack surface.

Minimize host permissions. If the extension operates on a specific set of domains, restrict host_permissions to those domains. For extensions that need to operate on the current tab in response to user action, use the activeTab permission instead — this grants temporary access only to the tab the user explicitly interacted with.

The Chrome extension permission documentation lists what each permission grants.

Message Validation in the Background Worker

Content scripts send messages to the background worker. An attacker who can run code in a content script can send arbitrary messages to the background worker via chrome.runtime.sendMessage. The background worker must not trust message content blindly.

// Vulnerable: trusts the url without validation
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'FETCH_URL') {
    fetch(message.url).then(r => r.text()).then(sendResponse); // SSRF
    return true;
  }
});

Validate message origin, validate content, and treat messages from content scripts with the same skepticism as messages from the web:

  • Verify sender.tab exists and matches expected tab state
  • Validate message schema strictly before processing
  • Don’t use message content in URLs, eval(), or DOM operations without sanitization

FAQ

Can a page inject into my content script’s isolated world? No. The isolated world prevents page JavaScript from reading content script variables. However, the page can modify the DOM, which the content script reads — never treat DOM values as trusted.

Is it safe to use jQuery in a content script? jQuery’s $() and .html() methods use innerHTML internally. Using jQuery to insert DOM content built from page data has the same XSS risks as using innerHTML directly. It’s safe to use jQuery for DOM traversal; be careful with any jQuery method that writes HTML.

How should I handle receiving JSON from the page context? Use JSON.parse() which doesn’t execute code (unlike eval()). Validate the parsed structure against the expected schema — don’t trust that the JSON has the expected shape.

Do Firefox extensions have the same security model for content scripts? Yes. The isolated world concept and DOM sharing are the same. Firefox’s extension security documentation at developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts covers their implementation. The attack patterns and mitigations apply equally.