The moment your content script runs alongside a hostile or buggy host page, the isolated world boundary is the only thing standing between your extension’s data and the page’s JavaScript. The symptom that brings developers here: a variable your content script wrote seems readable by host-page code, or host-page overwrites crash your script, or a CustomEvent payload arrives corrupted. The root of all three is the same — treating the isolated world as more permeable than it actually is, or less permeable than it needs to be. This guide is part of Content Scripts & DOM Injection.
Root cause: what the isolated world actually isolates
MV3 content scripts run in a JavaScript realm that shares the underlying DOM with the host page but has an entirely separate window object, its own prototype chain, and its own set of module globals. The host cannot access window.__myExtVar from your script because that window is a different object — and vice versa. What both sides can do is read and write to the same DOM nodes and fire DOM events.
The isolation fails in two common ways:
- Extending DOM node prototypes. If your content script does
Element.prototype.findParent = fn, the host page sees that mutation because Element.prototype lives in the DOM realm, not the JavaScript realm. Never extend built-in DOM prototypes. - Using
world: 'MAIN' without understanding that you have shed all chrome.* API access and stepped into an untrusted environment. Any data you read from the page — window.dataLayer, event detail, DOM attributes — must be treated as adversarial input.
Step 1: Encapsulate all extension state
Never attach extension state to window or any global. Use module-scope variables (ES modules) or IIFEs. The host page cannot reach them, but more importantly, your own scripts across multiple executeScript calls cannot accidentally share state through window either.
1// content/feature-flag-reader.ts (ISOLATED world, ES module)
2const _state = {
3 enabled: false,
4 userId: "",
5};
6
7export async function init() {
8 const { featureFlags } = await chrome.storage.sync.get("featureFlags");
9 _state.enabled = featureFlags?.highlight ?? false;
10 _state.userId = featureFlags?.uid ?? "";
11}
12
13export function isEnabled(): boolean {
14 return _state.enabled;
15}
Execution context: Content script in ISOLATED world. ES module semantics mean _state is private to this module instance. chrome.storage.sync is accessible from content scripts whenever the storage permission is declared — no message round-trip to the service worker required for reads. Firefox and Safari expose the same chrome.storage surface in content scripts.
Step 2: Bridge to the MAIN world via CustomEvent
When you genuinely need to hand data to page-level code — or read a value only accessible on the page’s window — use CustomEvent dispatched on document. The structured clone algorithm copies the payload cleanly between worlds, so neither side can smuggle a prototype-polluted object through.
1// content/bridge-sender.ts (ISOLATED world)
2export function sendToPage(type: string, payload: unknown) {
3 const evt = new CustomEvent(`mv3ext:${type}`, {
4 detail: JSON.parse(JSON.stringify(payload)), // double-serialise: strip any class instances
5 bubbles: false,
6 cancelable: false,
7 });
8 document.dispatchEvent(evt);
9}
10
11// Listen for the page's reply
12document.addEventListener("mv3ext:reply", (evt) => {
13 const raw = (evt as CustomEvent).detail;
14 // Validate before trusting
15 if (typeof raw?.status !== "string") return;
16 handleReply(raw.status);
17});
Execution context: Content script in ISOLATED world. CustomEvent fires synchronously in both worlds because they share the same DOM event loop. JSON.parse(JSON.stringify(...)) is intentional: it strips any class instances or prototype chains that the structured clone algorithm would otherwise carry across. Firefox behaves identically. Safari has historically had edge-case bugs with CustomEvent detail serialisation on older versions — test on Safari 16+.
Step 3: Receive MAIN world data safely
The page-side listener runs in the host’s JavaScript context. It fires a reply event back. Your ISOLATED world listener must treat that reply as untrusted, the same way a server treats user input.
1// content/bridge-receiver.ts (ISOLATED world)
2const ALLOWED_KEYS = new Set(["theme", "locale", "version"]);
3
4function sanitisePageData(raw: unknown): Record<string, string> {
5 if (typeof raw !== "object" || raw === null) return {};
6 const result: Record<string, string> = {};
7 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
8 if (ALLOWED_KEYS.has(k) && typeof v === "string") {
9 result[k] = v.slice(0, 256); // cap length
10 }
11 }
12 return result;
13}
14
15document.addEventListener("mv3ext:page-data", (evt) => {
16 const payload = sanitisePageData((evt as CustomEvent).detail);
17 chrome.runtime.sendMessage({ type: "PAGE_DATA", payload });
18});
Execution context: Content script in ISOLATED world. The structured clone boundary means the page cannot pass a Proxy or getter that runs arbitrary code — but it can still pass a malicious string value. Always validate types, key names, and lengths before forwarding to the service worker or writing to storage. Firefox and Chrome behave identically here.
Step 4: Harden MAIN world scripts against the host page
When you must run code in world: 'MAIN' (e.g., to call a page-defined function or read a live page global), capture pristine references to built-ins at the top of your function before the host page can overwrite them.
1// Injected via chrome.scripting.executeScript({ world: 'MAIN', func: mainWorldProbe })
2function mainWorldProbe(): string | null {
3 // Capture safe references before any host-page prototype pollution runs
4 const safeStringify = Object.prototype.toString;
5 const safeHasOwn = Object.prototype.hasOwnProperty;
6
7 const cfg = window.__APP_CONFIG__;
8 if (safeStringify.call(cfg) !== "[object Object]") return null;
9 if (!safeHasOwn.call(cfg, "apiBase")) return null;
10
11 const base = cfg.apiBase;
12 if (typeof base !== "string" || !/^https:\/\//.test(base)) return null;
13 return base;
14}
Execution context: Runs in the tab’s MAIN JavaScript context — no chrome.* APIs available. Return value is serialised back to the service worker through structured clone. Chrome 102+, Firefox 128+. Safari does not support world: 'MAIN'; use a CustomEvent bridge from the ISOLATED side instead and accept that you cannot call page functions directly.
Step 5: Never extend DOM prototypes
This is the subtlest violation because it does not throw — it silently crosses the isolation boundary.
1// WRONG — leaks into the host page's prototype chain
2HTMLElement.prototype.extHighlight = function () { /* ... */ };
3
4// RIGHT — use a standalone utility
5function extHighlight(el: HTMLElement) {
6 el.dataset.extHighlighted = "1";
7 el.style.outline = "2px solid #2563eb";
8}
Execution context: Content script in ISOLATED world. HTMLElement.prototype is shared because it belongs to the DOM realm, not the JS realm. The host page will see extHighlight on every element and may iterate over it in for...in loops. Firefox and Chrome share this behaviour. The fix is always the same: plain functions, not prototype mutations.
Cross-browser variation
- Chrome / Edge (102+): Full
world: 'MAIN' support. CustomEvent detail reliably crosses worlds. DOM prototype leaks reproduce as described. - Firefox (128+): Added
world: 'MAIN' but scripts in the MAIN world have no access to any WebExtension API. browser.scripting namespace required (not chrome.scripting). CustomEvent bridge works identically to Chrome. - Firefox (< 128): No
world: 'MAIN'. The CustomEvent bridge is the only option for MAIN-world data. Use browser.scripting polyfills carefully — they may introduce frame-targeting differences. - Safari:
world: 'MAIN' is not supported as of Safari 17. Use the CustomEvent pattern exclusively. Safari enforces per-site user permissions that can block content script injection even when the manifest is correct — handle NotAllowedError on every executeScript call.
Verification
- Open Chrome DevTools on any injected page, go to Sources → Content scripts. Each isolated world appears as a named context. Run
window.__myExtVar in the host-page console — it should return undefined even if your content script set a same-named variable. - Set
window.Array = null in the host-page console to simulate prototype pollution. Your content script should continue to function if it captured Array at module load time or uses only DOM APIs. - Add a temporary
console.log inside your CustomEvent listener and dispatch an event from the host page with a crafted detail object containing a getter function. Verify your sanitiser strips it before it reaches the service worker. - In Firefox, open about:debugging → This Firefox → your extension → Inspect to reach the content script console. Confirm
browser (not chrome) resolves there.
FAQ
Can I read host-page variables from an ISOLATED world content script?
No — not directly. window in your content script is a different object from window in the host page. Use a CustomEvent or window.postMessage to ask the page to send you the value, or use chrome.scripting.executeScript with world: 'MAIN' (Chrome/Firefox only) to retrieve the value and return it through the structured-clone boundary.
Why does my CustomEvent detail arrive as null in Firefox?
Firefox is stricter about non-serialisable values in CustomEvent.detail. If you pass a class instance, a DOM node, or a circular object, Firefox clones it as null. Always JSON.parse(JSON.stringify(payload)) before dispatching, or use only plain objects with primitive values.
Is window.postMessage safer or less safe than CustomEvent?
Both cross the world boundary and use structured clone. window.postMessage is better when the receiving code is a third-party script that already listens for messages (e.g., a page SPA). CustomEvent with a namespaced event name (e.g., mv3ext:init) is less likely to collide with existing page listeners and does not require origin validation when both sender and receiver are your own code on the same document.
Can I share a chrome.storage key between a content script and a MAIN world script?
Only the content script (ISOLATED world) can access chrome.storage. The MAIN world script has no extension API access at all. Write to storage from the ISOLATED side, then push results back to the page via CustomEvent if the page needs them.