← Back to Blog

world: 'MAIN' Is the One Injection Path CSP Kills

October 20269 min read

The Injection That Did Nothing

A user reports that per-site custom CSS "stopped working on one site". You open DevTools. The <style> element is in the DOM. Its textContent is 632 characters of perfectly valid CSS. There is no console error, no CSP violation warning in the extension's own context, no exception. And getComputedStyle shows the page completely unchanged.

That combination — element present, content correct, no error, zero effect — is the signature of a silent CSP refusal. It cost me an afternoon because every diagnostic I tried first ("is the CSS valid?", "did the message arrive?", "is the selector right?") returned a green light.

Three Ways to Inject CSS, Three Different CSP Outcomes

An extension can get CSS into a page three ways, and they are not equivalent under a strict Content Security Policy. I tested all three on Chrome/Edge 154 against pages sending style-src 'self' (no unsafe-inline):

PathStrict style-src 'self'No CSP
<style> created in world: 'MAIN'Refused silentlyWorks
<style> created in world: 'ISOLATED'WorksWorks
chrome.scripting.insertCSSWorksWorks

The important detail: Blink skips the inline-style CSP check for content scripts running in a non-main world. The style element lands in the same document and applies to the same DOM — it is simply exempt from a check that the main world is subject to.

Which means the original implementation picked the only one of the three paths that a strict CSP can kill. Not bad luck — a specific wrong choice, and one that produces no error message to point at it.

Why MAIN Looked Like the Right Choice

There are legitimate reasons to use world: 'MAIN': you need to read page JavaScript state, patch a page function, or observe something that only exists in the page's own realm. The mistake is generalising that requirement to all injections.

The code shape that caused the bug is common:

// Before — works on 95% of sites, silently dead on the rest
await chrome.scripting.executeScript({
  target: { tabId },
  world: 'MAIN',
  func: (css, id) => {
    const el = document.createElement('style');
    el.id = id;
    el.textContent = css;
    document.documentElement.appendChild(el);
  },
  args: [css, styleId]
});

Nothing here is wrong on a page with no CSP. The failure only appears on sites that ship style-src 'self' — which, unfortunately, is exactly the category of high-traffic sites users care most about.

The Fix, and the Comment That Keeps It Fixed

await chrome.scripting.executeScript({
  target: { tabId },
  // ISOLATED, not MAIN: the page's own CSP can refuse a <style> element
  // created from the main world — see the note by the font proxy above.
  world: 'ISOLATED',
  func: (css, id) => { /* unchanged */ },
  args: [css, styleId]
});

The code change is one word. The durable part is the comment, and the reason is embarrassing: this class of bug gets "fixed" back within a month. Someone sees ISOLATED, assumes it is a permission restriction, and switches to MAIN to "get full access". The repo now also carries a short "Injection world (do not fix)" note in AGENTS.md precisely because the comment alone was not enough.

The Same Bug in Three Other Extensions

Once you know the signature, you start finding it elsewhere in the same codebase:

The general lesson is not "always use ISOLATED". It is: pick the weakest world that satisfies the requirement, and treat MAIN as a deliberate escalation you justify in a comment.

Testing the Fallback Path Properly

Moving to the isolated world was not the end of it. The extension also runs a MutationObserver to re-apply styles when a site deletes them (some SPAs wipe <head> on route change). Changing the world changed the observer's environment, so it needed re-testing — and the naive tests were all false positives:

Note the pattern across all three: the assertion is on computed style and call counts, never on DOM presence. DOM presence was exactly the false signal that started this investigation.

How to Detect This Class of Bug

  1. Assert on effect, not on insertion. Read getComputedStyle(el).someProperty after injecting. If the extension's success criterion is "the element exists", it will report success forever.
  2. Log the page's CSP during development. One line — document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content plus a look at the response header — tells you whether you are testing the easy path.
  3. Listen for securitypolicyviolation on the page (isolated world is enough). A refusal that is "silent" in the console often still fires this event.
  4. Keep a strict-CSP test page in the repo. A local HTML file with style-src 'self' turns a 30-minute field diagnosis into a 5-second local check.

Checklist

  1. Default to the isolated world; escalate to MAIN only with a stated reason.
  2. Prefer chrome.scripting.insertCSS when you do not need dynamic toggling inside the page.
  3. Never verify injection by DOM presence — verify with getComputedStyle.
  4. Write the "why" comment next to the world argument; add it to your agent/contributor notes too.
  5. Re-test any MutationObserver fallback after changing injection world (dedupe + no-zombie assertions).
  6. Ship a strict-CSP fixture page so the regression is caught locally.

References

Related Articles