2020-02-23 11:06 — By Erik van Eykelen

Passing Data to and From an Extension Sandbox

Recently I worked on a browser extension which uses Libsodium to decrypt encrypted data on a web page. I quickly discovered that it is not wise to simply include sodium.js in the extension because Libsodium uses eval(). The elevated content security policy settings to allow eval() would lead to a rejection of the extension during Google's review process of the extension.

Luckily there's a workaround for this problem and it's called sandboxing.

In this post I describe how a browser extension can process web page content inside a sandboxed script, and what is needed to shuttle data from a web page (A), to the "safe" part of the extension (B), then to the "unsafe" part (C), and then traverse back again from C, to B, and finally to A.

The following files are discussed:

  • content.js
  • background.html which loads background.js
  • manifest.json
  • sandbox.html which loads sandbox.js

Let's begin with manifest.json:

{
  "name": "Extension",
  "version": "0.1",
  "description": "This is an example extension",
  "manifest_version": 2,
  "browser_action": {
    "default_icon": {
      "16": "images/16.png",
      "48": "images/48.png",
      "128": "images/128.png"
    },
    "default_title": "Example extension"
  },
  "background": {
    "page": "background.html",
    "persistent": false
  },
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "content.js"
      ]
    }
  ],
  "sandbox": {
    "pages": [
      "sandbox.html"
    ]
  }
}

If you're familiar with an extension's manifest file you'll see it's nothing special. Note that all three files we're going to discuss next are referenced: background.html, content.js, and sandbox.html.

The background.html file loads background.js and contains an iframe:

<!doctype html>
<html>
  <head><script src="background.js"></script></head>
  <body>
    <iframe id="extension-iframe" src="sandbox.html"></iframe>
  </body>
</html>

The iframe loads sandbox.html, the file which you can use to load scripts that may not be loaded inside the extension itself due to aforementioned content security policies. The sandbox is where I load the Libsodium JavaScript script file (for the use case I described in the preface).

First, let's look at content.js. This script is executed on every page load. Content scripts are cool because they're able to access the content of web pages and even make changes to the DOM:

// Send data via message to background.js
chrome.runtime.sendMessage({ hello: 'from contentjs' }, function (response) {
  console.log('contentjs')
  console.log(response)
})

The content.js script uses sendMessage() to send { hello: 'from contentjs' } to an event listener defined in background.js:

// Listen to messages sent by content.js
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
  // iframe is defined by background.html
  var iframe = document.getElementById('extension-iframe')

  // Send message to script living inside iframe
  iframe.contentWindow.postMessage(msg, '*')

  // Listen to messages sent by script living inside iframe
  window.addEventListener('message', function (event) {
    console.log('backgroundjs')
    // Reply to content.js
    sendResponse(event.data)
  }, { once: true })

  return true // Indicates async sendResponse behavior
})

Step by step:

  • The background.js script listens to messages using onMessage.addListener() sent by content.js.
  • Messages originating from content.js are passed on to the sandbox by calling postMessage().
  • In order to listen for messages returned by the sandbox another event listener is set up using window.addEventListener().
  • The { once: true } flag ensures this listener is only used once by automatically "cleaning it up" after receiving a response from the sandbox. By default this listener would have stuck around, which would lead to multiple instance of the same listener in case the content.js script would make repeated calls to background.js! See here for details.

The sandbox.js script is able to receive messages from background.js using the following code:

// Listen to messages sent by background.js
window.addEventListener('message', function (event) {
  console.log('sandboxjs')
  event.source.postMessage({ hello: 'from sandboxjs' }, event.origin)
})

The sandbox posts a message back to background.js, which in turn posts the message back to content.js (via the sendResponse() call).

In order to check whether everything is working as advertised you have to look in two different places when developing a browser extension:

  • The console() output of content.js is sent to the developer console of the current web page.
  • The console() output of background.js and sandbox.js is sent to another developer console namely the one of the extension itself (because extensions are autonomous browser instances). Go to chrome://extensions/ in your browser, toggle "Developer mode" to "on", and click the "background.html" link inside your extension's details card. This will open the console dedicated to your extension.

If you load the extension using the "Load unpacked" feature, open a web page, and then open its developer console you'll see this:

contentjs
{hello: "from sandboxjs"}

Likewise, if you open the extension's developer console you'll see this:

sandboxjs
backgroundjs

This "proves" that sandbox.js is able to sent data all the way from its iframe back to your web page.

The complete code discussed in this post can be found here: https://github.com/evaneykelen/chrome-extension-scaffolding

Comments

Check out my product Operand, a collaborative tool for due diligences, audits, and assessments.