Skip to content

Using WebExtensions to communicate between iframes

After my presentation at FOSDEM, I was approached by developers who had a specific use case for an add-on. They asked which of our add-on technologies they should use to implement it, and whether it was practical to do. I knew it was doable, and I thought it would be an interesting programming exercise, so I gave it a shot.

Their problem concerns a government website that was particularly difficult to use and not very easy on the eyes. I think we all know the type. The add-on should make the site more manageable. So far, this is a straightforward content script. What makes this case trickier is that the site uses iframes to display parts of its contents, and the add-on should take the contents of those frames and embed them into the top document. My add-on will do this for a minimal test case, using the new WebExtensions API.

The first thing I need to work on this add-on is a page to test it with, so I created this one.

Test page

Test page. Note the green borders around the iframes.

It’s a very basic HTML page that has two iframes, one pointing to a page in the same domain and another one pointing to example.com. Using a different domain matters because certain iframe interactions are subject to same-origin policies and I wanted to ensure this add-on worked for all cases.

Permissions

Other than the basic metadata, my manifest.json file has this:

"content_scripts": [
  {
      "matches": [
        "http://xulforge.com/mozilla/iframe-example/*",
        "http://example.com/*" ],
    "all_frames": true,
    "js": [ "scripts/flatten.js" ]
  }
],

"background": {
  "scripts": ["background.js"]
}

I declare the script using content_scripts, giving it access to both domains involved. It’s important to set the all_frames flag so that the content script is loaded into the internal frames of the page. Otherwise it is only loaded for top-level documents.

I also declare a background script, which will act as a message broker between the frames and the main document.

Messaging

Messaging is the key element in this implementation. We need the top-level document to obtain the contents of the frames and then swap that content for the iframe nodes. Since different documents can be potentially running in different processes, message-passing is necessary. The frames will send their contents to the main document, through the background script:

if (window != window.top) {
  // frame. Send message to background script.
  browser.runtime.sendMessage(
    { "host" : window.location.hostname,
      "body" : document.body.innerHTML });
}

The frames use sendMessage() to pass the message to the background script. The message also includes the host so it’s easier to tell one frame from the other.

The background script is listening for these messages:

browser.runtime.onMessage.addListener(function(message, sender) {

and then forwards them to the main document:

browser.tabs.sendMessage(sender.tab.id, messages[0]);

Note that the sender argument received from the frame message includes information of its tab of origin, which is necessary in order to us know where to send the message back. The sender object also includes a frame ID, which could be useful in more complex scenarios.

Finally, the main document receives the message and swaps the frame for its contents:

browser.runtime.onMessage.addListener(function(message, sender) {
  // ...
  // XXX: message.body should be sanitized before injecting!
  container.innerHTML = message.body;
  // inject content from frame.
  frame.parentNode.insertBefore(container, frame);
  // remove frame.
  frame.parentNode.removeChild(frame);
  // ...
});

I’ll reiterate here that using innerHTML in this way is very unsafe, since it opens the top document to XSS attacks. The contents of message.body should be sanitized before they are injected.

Coordinating messages

Here it got interesting. I assumed that the content script for the main document would always load before the iframes, which would make managing the messages fairly easy, but that was not the case. During my testing, the content script on the first iframe loaded first. This means the background script in the extension needs to cache at least that first message until the message listener in the main document becomes available. The first message is lost if it’s forwarded right away.

To make the implementation close to correct, I have the background script wait for the 3 documents to be loaded before forwarding the messages to the main document:

if (message.loaded) {
  // top document has loaded.
  loaded = true;
} else {
  // iframe has loaded.
  messages.push(message);
}

if (loaded && messages.length == 2) {
  // forward messages back to tab so the top window will use it.
  browser.tabs.sendMessage(sender.tab.id, messages[0]);
  browser.tabs.sendMessage(sender.tab.id, messages[1]);
  // clear state.
  loaded = false;
  messages = [];
}

I set message.loaded for the message coming from the main document. If that flag isn’t set, the message is coming from one of the iframes. When all 3 documents have reported themselves, both messages are forwarded to the main document, and the state is cleared for future loads.

This solution isn’t complete. It doesn’t take into account concurrent loads. This could be fixed with a map that is indexed by the sender tab id. It also doesn’t handle cases like when an iframe didn’t load, or when one of the iframe pages is loaded as the main document. These are all solvable problems but require more code than this simple example.

Testing

You can find the sources here and build the XPI for yourself. You can also download the built XPI here. Loading the test page with the add-on installed should look like this:

Test page with add-on installed

Note the entire contents are now visible, and the borders turn red to make it easier to tell the add-on worked.

That’s it! Hope this helps someone with their add-on. Let me know in the comments if you have any questions.

Post a Comment

Your email is never published nor shared. Required fields are marked *