Bridging Data Between MAIN World and Isolated World
Move data between an injected MAIN-world script and your isolated content script safely: CustomEvent handshakes, origin checks, request correlation, and what not to expose.
Table of Contents
- Root cause: two V8 contexts, one document
- Step 1. Inject the MAIN-world script from the worker
- Step 2. Define one event pair and one correlation id
- Step 3. Answer requests on the MAIN side, narrowly
- Step 4. Correlate and time out on the isolated side
- Step 5. Validate everything that comes back
- Cross-browser variation
- Verification
- FAQ
- Related
Your world: "MAIN" script can read the page’s framework state, and it cannot call chrome.runtime. Your isolated content script can call chrome.runtime, and it cannot see a single page variable. The data you need is on one side and the extension is on the other, and the only thing they share is the DOM. This page is part of Scripting API & Dynamic Injection and builds the bridge between them without handing the page a way into your extension.
Root cause: two V8 contexts, one document
The isolated world exists precisely so a hostile page cannot reach your extension. The MAIN world exists precisely so you can reach the page. Bridging them re-opens a door the browser deliberately closed, so the bridge has to be narrow, one-directional in intent, and suspicious of everything crossing it.
Step 1. Inject the MAIN-world script from the worker
The isolated script cannot create a MAIN-world script by appending a <script> tag — the extension content security policy blocks inline code, and a remote source is refused at review. Inject it properly.
1// sw.js
2chrome.action.onClicked.addListener(async (tab) => {
3 await chrome.scripting.executeScript({
4 target: { tabId: tab.id },
5 files: ["bridge-main.js"],
6 world: "MAIN",
7 injectImmediately: true,
8 });
9});
Execution context: Service worker; requires the scripting permission plus host access or activeTab. injectImmediately matters when the page’s own state is created early — the default waits for the document to settle, by which time a single-page application may have replaced the object you wanted to read. The file must also be listed in web_accessible_resources if the page could navigate to it directly; injecting by file rather than by string is what keeps the extension reviewable, as covered in safely using remote config without remote code.
Step 2. Define one event pair and one correlation id
Two event names, a request id, and nothing else. A general-purpose message bus across this boundary is a vulnerability with a nice API.
1// bridge-protocol.js — a plain module, imported into both bundles
2export const REQUEST = "tabcurator:req";
3export const REPLY = "tabcurator:res";
4
5export function newId() {
6 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
7}
Execution context: Build-time module, bundled into both the MAIN-world file and the isolated content script — it contains no API calls, so it is safe in either. Namespacing the event names with your extension avoids collisions with the page’s own events, which do exist: generic names like message or data are used by real sites and will produce events you did not send.
Step 3. Answer requests on the MAIN side, narrowly
The MAIN-world script should expose specific answers, not arbitrary evaluation.
1// bridge-main.js — runs in the page's own context
2import { REQUEST, REPLY } from "./bridge-protocol.js";
3
4const HANDLERS = {
5 routeName: () => window.__APP__?.router?.current?.name ?? null,
6 cartTotal: () => Number(window.__APP__?.store?.cart?.total ?? 0),
7 // No "eval" handler. No "getGlobal(path)" handler. Enumerated answers only.
8};
9
10document.addEventListener(REQUEST, (event) => {
11 const { id, op } = event.detail ?? {};
12 const handler = HANDLERS[op];
13 const detail = handler
14 ? { id, ok: true, value: handler() }
15 : { id, ok: false, error: "unknown-op" };
16
17 document.dispatchEvent(new CustomEvent(REPLY, { detail }));
18});
Execution context: MAIN world — same JavaScript context as the page, so window.__APP__ is visible and chrome.* is not. The enumerated HANDLERS map is the security boundary: a generic path-reader would let anything that can dispatch the request event read any page global, and the page can dispatch that event. Behaviour is identical in Chrome, Firefox and Safari 17+, though Safari before 17 has no MAIN world at all, so probe for it and skip the feature.
Step 4. Correlate and time out on the isolated side
1// content.js — isolated world
2import { REQUEST, REPLY, newId } from "./bridge-protocol.js";
3
4const pending = new Map();
5
6document.addEventListener(REPLY, (event) => {
7 const { id, ok, value, error } = event.detail ?? {};
8 const entry = pending.get(id);
9 if (!entry) return; // not ours, or already timed out
10 pending.delete(id);
11 clearTimeout(entry.timer);
12 ok ? entry.resolve(value) : entry.reject(new Error(error ?? "bridge-failed"));
13});
14
15export function askPage(op, { timeoutMs = 1500 } = {}) {
16 const id = newId();
17 return new Promise((resolve, reject) => {
18 const timer = setTimeout(() => {
19 pending.delete(id);
20 reject(new Error("bridge-timeout")); // MAIN script absent or the page navigated
21 }, timeoutMs);
22
23 pending.set(id, { resolve, reject, timer });
24 document.dispatchEvent(new CustomEvent(REQUEST, { detail: { id, op } }));
25 });
26}
Execution context: Isolated world content script. The timeout is not optional: if the MAIN script failed to inject — a restricted page, a Safari version without MAIN-world support, a navigation between injection and request — the promise would otherwise never settle and the caller would hang forever. Ignoring replies with an unknown id is what makes the bridge safe against a page that dispatches forged replies; it can still forge a reply with a guessed id, which is why the next step exists.
Step 5. Validate everything that comes back
The page can dispatch REPLY events itself. Anything crossing the bridge is user input from a hostile source.
1// content.js
2const SHAPES = {
3 routeName: (v) => (typeof v === "string" && v.length < 200 ? v : null),
4 cartTotal: (v) => (Number.isFinite(v) && v >= 0 && v < 1e9 ? v : null),
5};
6
7export async function readPage(op) {
8 const raw = await askPage(op).catch(() => null);
9 const clean = SHAPES[op]?.(raw) ?? null;
10 if (clean === null) return null;
11
12 // Only validated primitives ever reach the worker.
13 await chrome.runtime.sendMessage({ type: "PAGE_STATE", op, value: clean });
14 return clean;
15}
Execution context: Isolated world. Validating at the boundary rather than at the consumer means there is exactly one place to audit. Never forward the raw event.detail object to the worker: it is structured-cloned across the process boundary and a deeply nested object from the page becomes untrusted data inside your privileged context. The same discipline applies to anything the page can influence, as set out in best practices for content script isolation in MV3.
Cross-browser variation
- Chrome/Edge:
world: "MAIN"supported since Chrome 111 forexecuteScriptand viaregisterContentScripts.CustomEventdetail crosses between worlds as a structured clone, so functions and DOM nodes are stripped. - Firefox: MAIN world supported from Firefox 128 for
scripting.executeScript. Firefox also offerswrappedJSObjectandexportFunctionfor reaching page globals from the isolated world directly — engine-specific, so keep it behind a probe if you use it. - Safari:
world: "MAIN"requires Safari 17. On older versions the bridge silently has no other side, which the request timeout turns into a clean failure rather than a hang. - All engines: the page sees every event you dispatch on
document. Assume anything you send is public, and never send a token, a user identifier, or extension state.
Verification
- In the page console (MAIN world), run
document.dispatchEvent(new CustomEvent("tabcurator:res", { detail: { id: "x", ok: true, value: "forged" } })). Nothing should happen — no pending entry has that id. - Ask for an unknown op and confirm the promise rejects with
unknown-oprather than hanging. - Remove the MAIN injection and confirm
askPagerejects withbridge-timeoutafter the configured delay. - Fire two requests concurrently and confirm each resolves with its own answer.
- Return a hostile value from a handler — a 10 MB string — and confirm the shape validator rejects it before it reaches the worker.
FAQ
Can I use window.postMessage instead of CustomEvent?
You can, and it is worse here: postMessage events also arrive from iframes and other origins, so you inherit an origin check you would not otherwise need. CustomEvent on document stays within the one document.
Is a shared window property simpler than events?
The isolated world cannot see MAIN-world properties at all, so there is nothing to share. Events are the only channel.
Should the MAIN script talk to the service worker directly?
It cannot — chrome.runtime does not exist there. Everything must pass through the isolated script, which is also where validation belongs.
What if I only need to read one value once?
Then inject a MAIN-world function with executeScript and use its return value: the InjectionResult comes straight back to the worker, no bridge required. The bridge is for ongoing, two-way conversation.
Related
- Injecting only after a user gesture with activeTab — the permission model behind the injection in step 1.
- Injecting CSS and JS with executeScript — return values, args and frame targeting.
- Best practices for content script isolation in MV3 — why the boundary exists in the first place.
- Scripting API & Dynamic Injection — the guide this page belongs to.