Modifying document window and communicating with the background script

Hey, I been trying to create an extension instead of using an user script extension. I have ran into a problem with how I can modify the window of the page and still communicate with my background script.

I have tried using window.wrappedJSObject to access the actual window it kind of works but when I tried modifying a method, the page scripts will error with permission errors.

I want to modify some logic by overwriting a specific event listener. My plan is to hook into EventTarget.prototype.addEventListener then overwrite the callback function. How do I do this while still being able to communicate with my background script.
I think it might be possible using the userScripts api but it being a optional permission has caused me some problems so I would rather find other solutions which work in content pages if possible.

Hey @junkie25! Sounds like you’re running into issues related to content script environments or “worlds.” The short version is that a content script can either run in a separate JavaScript environment from the page (the extension’s ISOLATED world) that shares access to the same underlying DOM tree, or in the same environment as the page’s own scripts (the MAIN world).

While it’s possible for extension authors to pierce the barrier of these JS environments in Firefox using xray vision, I’d generally recommend against doing so. The xray vision API is a bit tricky to work with and even harder to do so safely. This technique also doesn’t work in other browsers.

Instead, I’d suggest using a pair of content scripts (a MAIN and ISOLATED script) to accomplish what you’re describing. The MAIN script will handle monkey-patching the page’s JS environment while the ISOLATED script will handle exchanging messages with your extension’s background context. All that’s left then is to exchange messages between worlds.

As of today there’s no WebExtensions platform-provided feature that allows scripts to communicate with each other across worlds. Instead, we have to lean on the existing features of the web platform to implement a message passing solution between these scripts. Extension developers commonly do this by transmitting data across worlds using a CustomEvent with a well-known name. Both scripts should be injected at document_start (see runAt) in order to perform their setup before page scripts have an opportunity to load. Here’s a minimal example:

manifest.json
{
  "name": "Cross-world Message Communications Demo",
  "version": "1.0",
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["*://example.com/*"],
      "js": ["content-main.js"],
      "run_at": "document_start",
      "world": "MAIN"
    },
    {
      "matches": ["*://example.com/*"],
      "js": ["content-isolated.js"],
      "run_at": "document_start",
      "world": "ISOLATED"
    }
  ]
}
content-isolated.json
// Generate a random event name to (a) avoid conflicts with other scripts on the
// page and (b) prevent other scripts from easily monitoring our communications.
const customEventName = Math.random().toString(36).slice(2);

// Now that we have a custom event name, we can set up a session-specific
// listener.
window.addEventListener(`${customEventName}-isolated`, (event) => {
  console.log('content-isolated.js received message:', event.detail);
});

// Now that we've set up our session-specific listener, we can pass the custom
// event name to the MAIN script so it can set up its own listener.
document.dispatchEvent(new CustomEvent('cwc-setup', {detail: customEventName}));

function sendMessage(detail) {
  // Send a message to the main world
  const event = new CustomEvent(`${customEventName}-main`, {detail});
  window.dispatchEvent(event);
}

// Once MAIN binds it's own listeners for the session-specific messages, we can
// start sending messages.
sendMessage('Hello from the ISOLATED world!');
content-main.js
// This script is injected into the same JS context as all other page scripts.
// We use a IIFE to prevent our logic it from polluting that environment.
(() => {
  // First, we must wait for the ISOLATED script to pass us the custom event
  // name so we can set up a session-specific communication channel.
  const customEventSetup = new Promise((resolve) => {
    document.addEventListener('cwc-setup', (event) => {
      event.stopImmediatePropagation();
      resolve(event.detail);
    }, {once: true, capture: true});
  });
  // Once we have the custom event name, we can start listening for messages
  // from the ISOLATED script.
  customEventSetup.then(customEventName => {
    window.addEventListener.call(window, `${customEventName}-main`, (event) => {
      console.log('content-main.js received message:', event.detail);
    });

    function sendMessage(detail) {
      // Send a message to the isolated world
      const event = new window.CustomEvent(`${customEventName}-isolated`, {detail});
      window.dispatchEvent.call(window, event);
    }
    // Now that the session-specific communication channel is set up, we can
    // start sending messages.
    sendMessage('Hello from the MAIN world!');
  });
})();

Finally, a brief note on security: if you are concerned about a website seeing the data you transmit back to your extension, you will have to take extra measures to harden your code. This includes protective measures in your ISOLATED content script like saving unmodified references built-ins at document_start (example from @rob) and treating any messages passed from your content scripts to your background context as untrusted (which you should do anyway).

2 Likes