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):
| Path | Strict style-src 'self' | No CSP |
|---|---|---|
<style> created in world: 'MAIN' | Refused silently | Works |
<style> created in world: 'ISOLATED' | Works | Works |
chrome.scripting.insertCSS | Works | Works |
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:
- Note-taking extension — five separate
world: 'MAIN'injections, all removed. None of them actually needed page-realm access; they were copy-pasted from an early prototype. - Site blocker — cannot inject a
<style>at all on YouTube and TikTok (both send very strict CSP). Its CSS ships in the manifest instead of being injected at runtime. - Font tool — fetches Google Fonts CSS, inlines every
woff2as adata:URL, then injects plain@font-facerules. The fetch happens in the service worker; only the final injection touches the page, in the isolated world.
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:
- Observer still fires? Delete the style element manually, then check
getComputedStyle(document.body).backgroundColor— not the presence of the element, which tells you nothing about whether CSP or a race ate it. - Double-apply does not duplicate observers? Call apply twice, delete the style once, and assert exactly one re-application. Two observers would produce two re-applications, which is invisible in the DOM but shows up as duplicated work and, in this extension, doubled storage writes.
- No zombie after removal? Remove the style, delete it again 1.2 s later, and assert nothing comes back. If the observer was never disconnected, the style resurrects and the user believes removal failed.
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
- Assert on effect, not on insertion. Read
getComputedStyle(el).somePropertyafter injecting. If the extension's success criterion is "the element exists", it will report success forever. - Log the page's CSP during development. One line —
document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.contentplus a look at the response header — tells you whether you are testing the easy path. - Listen for
securitypolicyviolationon the page (isolated world is enough). A refusal that is "silent" in the console often still fires this event. - 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
- Default to the isolated world; escalate to
MAINonly with a stated reason. - Prefer
chrome.scripting.insertCSSwhen you do not need dynamic toggling inside the page. - Never verify injection by DOM presence — verify with
getComputedStyle. - Write the "why" comment next to the world argument; add it to your agent/contributor notes too.
- Re-test any
MutationObserverfallback after changing injection world (dedupe + no-zombie assertions). - Ship a strict-CSP fixture page so the regression is caught locally.
References
- chrome.scripting API — Chrome Docs
- Content scripts and isolated worlds — Chrome Docs
- Content Security Policy — MDN
- SecurityPolicyViolationEvent — MDN