Categories
Coding

Sandboxing with Partytown

Partytown is a “library to help relocate resource intensive scripts into a web worker, and off of the main thread. Its goal is to help speed up sites by dedicating the main thread to your code, and offloading third-party scripts to a web worker.” Code running in a worker runs in a separate thread, ensuring that long tasks do not cause any jank, thus improving FID and INP. Relocating existing code to a worker is challenging, however, because a worker does not have access to many browser APIs that are exclusively available on the main thread. Partytown solves this problem by extensively using ES6 proxies to replicate the main thread’s API surface in the worker. Each DOM API call in the worker gets proxied back to the main thread, while ensuring the tasks are batched and broken up to avoid jank-inducing long tasks.

The fact that Partytown proxies all API calls to the main thread means it can do more than just improve performance. Since the API calls are proxied, Partytown also has the goal to “Sandbox third-party scripts and allow or deny their access to main thread APIs.” These two goals of performance and sandboxing highlight Partytown as being a possible alternative library to WorkerDOM, which is used by amp-script to ensure that third-party scripts do not violate AMP’s constraints. Nevertheless, writing code for WorkerDOM can be challenging since there are quite a few DOM APIs which are not available. Partytown’s goal of being able to run existing code without any modifications seems like a key advantage. So how then is code able to be sandboxed when orchestrated by Partytown?

It turns out Partytown’s documentation for sandboxing is lacking. All that exists is a high level description of what it allows, as was similarly described in an interview with the creator of Partytown. There is even an open issue for adding the missing Sandboxing documentation with the added label “help wanted”. 

So I set out to understand the sandboxing capabilities of Partytown and how they can be utilized. I also discovered several shortcomings which (spoiler alert) render Partytown in its current state as being insufficient for rigorous sandboxing.

Hooks

Partytown’s documentation lists five configuration options, none of which seem related to sandboxing. From looking at the PartytownConfig interface in the source code, however, there are actually a total of 19 configuration options. There are a few options for logging (e.g. logCalls, logGetters, logSetters) which can be used to monitor the APIs that the sandboxed code is calling. There are also three configuration options which lack any code comments which appeared to relate to the sandboxing:

  get?: GetHook;
  set?: SetHook;
  apply?: ApplyHook;Code language: plaintext (plaintext)

From the typing it appeared that these configuration properties allow for callback functions to be invoked when a sandboxed script gets a property from an object, sets a property on an object, or calls a method on an object. These callbacks are passed objects containing hook options which contain the context for the proxied call. The possible properties of the hook options include:

PropertyTypeHooksDescription
instanceProxyallWhen getting, setting, or applying a property of an object, this is the proxy for that object. 
nodeNamestring | undefinedallThe nodeName of the instance object, if it is a DOM node (e.g. "DIV"). In the case of a setter for a CSSStyleDeclaration, this is actually an object containing a batched update of properties being applied.
constructorstring | undefinedallThe constructor of the instance object (e.g. "HTMLDivElement", "Window")
namestringallThe instance property being set/get (e.g. "width"), or the name of the function that is being called on the instance (e.g. "insertBefore")
argsany[]applyThe arguments array being passed when calling a function.
valueanysetThe value that is being set on the instance object.
windowProxyallProxy object for the window global. This can be used to obtain the document proxy object.
continueSymbolallWhen returned by the hook callback, the pending operation will proceed as normal. If a hook callback does not return this value, then whatever value is returned will be what is used for the current operation: a return value for a getter, a value being set on an instance, or the return value from a method call.
preventSymbolsetWhen returned by a set hook callback, this will short-circuit the setter from proceeding. This is needed because not returning the continue symbol (above) would otherwise use that value to be set.

Let’s see some more examples of the properties of HookOptions that are passed into the hooks. In each case, only the last hook’s options are shown as there could be multiple hooks triggered in a given statement. In the subsequent section, I’ll show how to use the hook options in hook callbacks.

Note that instance and window were recently added in 0.7.2 via my pull request.

Examples of get hook options

Getting cookies

x = document.cookie;Code language: JavaScript (javascript)

Resulting hook options:

{
  "name": "cookie",
  "nodeName": "#document",
  "constructor": "HTMLDocument",
  "continue": Symbol(),
  "instance": Proxy<Document>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Getting an image’s width

x = document.querySelector('img').width;Code language: JavaScript (javascript)

Resulting hook options:

{
  "name": "width",
  "nodeName": "IMG",
  "constructor": "HTMLImageElement",
  "continue": Symbol(),
  "instance": Proxy<HTMLImageElement>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Getting the body background color

x = document.body.style.background;Code language: JavaScript (javascript)

Resulting hook options:

{
  "name": "background",
  "nodeName": {"background": ""},
  "constructor": "CSSStyleDeclaration",
  "continue": Symbol(),
  "instance": Proxy<CSSStyleDeclaration>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Examples of set hook options

Setting a cookie

document.cookie = "foo=bar";Code language: JavaScript (javascript)

Resulting hook options:

{
  "value": "foo=bar",
  "name": "cookie",
  "nodeName": "#document",
  "constructor": "HTMLDocument",
  "continue": Symbol(),
  "prevent": Symbol(),
  "instance": Proxy<HTMLDocument>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Setting the width of an image

x = document.body.style.background;Code language: JavaScript (javascript)

Resulting hook options:

{
  "value": 1024,
  "name": "width",
  "nodeName": "IMG",
  "constructor": "HTMLImageElement",
  "continue": Symbol(),
  "prevent": Symbol(),
  "instance": Proxy<HTMLImageElement>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Setting the body background color

document.body.style.background = 'black';Code language: JavaScript (javascript)

Resulting hook options:

{
  "value": "black",
  "name": "background",
  "nodeName": {
    "background": "black"
  },
  "constructor": "CSSStyleDeclaration",
  "continue": Symbol(),
  "prevent": Symbol(),
  "instance": Proxy<CSSStyleDeclaration>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Examples of apply hook options

Appending an image to the body

document.body.appendChild(img);Code language: JavaScript (javascript)

Resulting hook options:

{
  "args": [
    Proxy<HTMLImageElement>,
    null
  ],
  "name": "insertBefore",
  "nodeName": "BODY",
  "constructor": "HTMLBodyElement",
  "continue": Symbol(),
  "instance": Proxy<HTMLBodyElement>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Note that even though appendChild was called, it’s actually the more general-purpose insertBefore method that ends up getting called.

Showing an alert modal

alert("Hello World");Code language: JavaScript (javascript)

Resulting hook options:

{
  "args": [
    "Hello World"
  ],
  "name": "alert",
  "constructor": "Window",
  "continue": Symbol(),
  "instance": Proxy<Window>,
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Adding a click event listener to a button

btn.addEventListener('click', (e) => console.info(e))Code language: JavaScript (javascript)

Resulting hook options:

{
  "args": [
    "click",
    (e) => console.info(e)
  ],
  "name": "addEventListener",
  "nodeName": "BUTTON",
  "constructor": "HTMLButtonElement",
  "continue": Symbol(),
  "instance": Proxy<HTMLButtonElement>
  "window": Proxy<Window>
}Code language: JavaScript (javascript)

Example Hook Callbacks

Given the above hooks and the context objects they are passed, let’s see some examples now of what you can actually do with the hook callbacks. These examples all involve the Partytown configuration which is a global partytown variable in the page. 

Let’s say you want to block access to cookies and provide something else in return, you can do so with this get hook callback (see on Glitch):

var partytown = {
  get(opts) {
    if (opts.nodeName === "#document" && opts.name === "cookie") {
      return "partying=true";
    }
    return opts.continue;
  },
};Code language: JavaScript (javascript)

Conversely, you can prevent a sandboxed script from setting a cookie with this set hook callback:

var partytown = {
  set(opts) {
    if (opts.nodeName === "#document" && opts.name === "cookie") {
      console.warn(`Blocked setting cookie to ${opts.value}`);
      return opts.prevent;
    }
    return opts.continue;
  },
};Code language: JavaScript (javascript)

Now, using an apply hook callback, you could use this to prevent sandboxed code from inserting an image that lacks width or height, thus preventing possible layout shift (see on Glitch):

var partytown = {
  apply(opts) {
    const [arg] = opts.args;
    if (
      opts.name === "insertBefore" &&
      arg.nodeName === "IMG" &&
      (!arg.width || !arg.height)
    ) {
      console.warn('Blocked insertion of unsized image.');
      return null;
    }
    return opts.continue;
  },
};Code language: JavaScript (javascript)

And here’s how you can override an alert() call to create an modal by instead adding an HTML5 dialog element (see on Glitch):

var partytown = {
  apply(opts) {
    if (opts.name === 'alert') {
      const doc = opts.window.document;
      const dialog = doc.createElement('dialog');
      dialog.open = true;
      dialog.appendChild(doc.createTextNode(opts.args[0]));
      doc.body.appendChild(dialog);
      return undefined;
    }
    return opts.continue;
  },
};Code language: JavaScript (javascript)

I’ve put together more examples on GitHub, along with a toggle to turn Partytown on and off. I’ve deployed the examples to a Glitch.

Hook Limitations

While investigating use cases for hooks and trying out what is possible, I found several challenges that limited how hooks in Partytown can be used for sandboxing.

Execution Scope

It’s important to note that hook callbacks defined in the Partytown configuration are all executed in the context of the web worker. When the worker is constructed, the hook callback functions are serialized and sent to the worker to be evaluated. This means that hooks cannot reference any code outside of their function definitions, such as any shared functions or variables in the lexical scope: they must be self-contained. 

Additionally, since hook callbacks are evaluated in the context of the worker, they naturally do not have access to any APIs in the main thread. Critically this means that the HTML Sanitizer API, which is not available in a worker context, cannot be leveraged to sanitize DOM changes in a sandboxed script via Partytown hooks. 

Lastly, evaluating sandboxing hooks in the worker context seems a bit backwards. The hooks contain trusted code which should run in a trusted context (on the main thread) which is sanitizing the API calls from the unrusted context in the worker.

The three previous points could be addressed if additional hooks were added which run on the main thread instead of in the worker.

Contextual Awareness

A use case for sandboxing is to restrict DOM operations to a given subtree. This is not always feasible using Partytown hooks. If mutating a DOM element directly, a hook callback can walk up the node’s ancestors via instance.parentNode to determine if it is located in the desired DOM subtree. However, if it is the style of an element which is being modified then there is no such parentNode reference. It is therefore not currently possible to constrain style manipulations to a given subtree (as far as I can determine).

Another use case related to subtree containment is restricting one third-party script to be able to mutate one subtree and another third-party script to mutate another. This is also not possible at present because hook callbacks are not provided information about the current script being executed. It would be useful to know the original script element that triggered the current hook so that the callback can contextually determine the validity of the call.

Isolation

All sandboxed scripts share the same worker environment. This means that if one sandboxed script is performing poorly then all other sandboxed scripts will also be negatively affected.

In terms of security, it doesn’t seem ideal to have multiple scripts of varying trustworthiness running in the same context. While the scripts do get evaluated with namespacing to protect the global worker scope, they still share the global window proxy object, meaning they are not isolated from each other. Ideally each sandboxed script would run in its own worker, and the APIs they have access to would be constrained on a per-script basis.

API Coverage

Not all DOM APIs are sandboxed with hooks. In particular, I found that calling getItem on localStorage/sessionStorage cannot be intercepted with the apply hook, even though the other Storage interface methods can be intercepted (setItem, removeItem, clear). If there is sensitive information in browser storage, this can’t currently be protected. There may be other such APIs as well.

Conclusion

Partytown’s sandboxing is not currently robust enough to securely run untrusted third-party code. Its key advantage is that existing DOM code can be sandboxed without having to rewrite it to work around missing APIs that WorkerDOM doesn’t support. With Partytown the sandboxed code still needs to be fundamentally trusted, whereas WorkerDOM is able to execute much less trustworthy code. (Also note Partytown skips sandboxing altogether in IE11.)

In its current state, Partytown’s sandboxing is primarily useful for logging what APIs are being invoked by sandboxed scripts and making some hacks to the executed code. Nevertheless, it’s great to see another example of using web workers to isolate third party code, and as I outlined above, perhaps Partytown’s sandboxing can be made more robust to guarantee the security and performance of executed scripts.

2 replies on “Sandboxing with Partytown”

Thanks for this blog post. This sounds like it could probably catch a bad/malicious script by logging what it is doing, but if someone wanted to specificly write a scripts to get around or detect that it was being run in a sandbox they probably still could.

Leave a Reply

Your email address will not be published. Required fields are marked *