0.18.1-alpha.5 • Published 4 years ago

@component-sandbox/core v0.18.1-alpha.5

Weekly downloads
6
License
ISC
Repository
github
Last release
4 years ago

@component-sandbox

@component-sandbox/core is a JavaScript library based on iFrameResizer with adaptive height and messaging abstraction.

The goal of this project is to create a secure sandbox around UI components to support a seamless integration of custom components and code into a host application. The @component-sandbox/core allows for establishing a dedicated message channel between the host application and the sandboxed code. Internally it also uses the iFrameResizer library to automatically resize the IFrame's height whenever a mutation of the sandboxed contents is detected in order to achieve a seamless visual user experience.

Browser Compatibility

The @component-sandbox/core has been tested with:

  • IE: 10 and 11 (older versions won't work due to the internal use of the Channel Messaging API)
  • Edge: 13 - 18
  • Chrome: 26 - 71
  • Firefox: 41 - 64
  • Safari: 8 - 12
  • Opera: 56 and 57

This given browser matrix doesn't mean that the code isn't compatible with earlier browser versions, it just means that we weren't able to test it in earlier versions so far.

Security

Every sandboxed IFrame mandatorily gets initialised with the sandbox attribute in place. Also the availability of the allow-scripts restriction lift is required and enforced. Besides that you are free to lift all other sandbox restrictions if required for your use case, including the allow-same-origin restriction.

Exception: As usual Internet Explorer 10 and 11 require some special treatment as unfortunately cross-domain communication is broken in IE since the very beginning and got fixed in Edge only. For us this means that for these legacy browsers we need to weaken the sandbox by lifting the allow-same-domain restriction mandatorily. This is the bullet we have to bite in order to get the inter-frame communication to run in these browsers.

Inter-Frame Communication

The @component-sandbox/core establishes a simple pub/sub communication channel between the host and the sandbox via globally available subscribe and emit functions. The Channel Messaging API is used under the hood to directly communicate between the host window and the sandboxed IFrame's contentWindow. As described here, the Window.postMessage() API is utilized to establish the direct connection.

Inter-Sandbox Communication

It's possible to define one or more Transferable objects that can be passed via the emit and extracted by the subscribe method. Utilizing this feature paves the way for any kind of mediators/services to establish direct connections between several sandboxes.

Installation

npm i @component-sandbox/core --save

or

yarn add @component-sandbox/core

Usage

  1. Import the @component-sandbox/core library in your code

    import sandbox from '@component-sandbox/core';
  2. Either create an IFrame node programmatically and attach it to the document, or simply skip this step if you already have an IFrame node in your markup.

    // helper with base attributes for IFrame, you can also create an IFrame node by yourself
    const frame = sandbox.frame();
    
    document.body.appendChild(frame);
  3. Initialise the sandbox

    // Any valid html markup can be used, content is injected into the IFrame body
    // Be aware of the fact that the code is injected as-is, means you need to make sure
    // that the injected code is suitable for the browsers you are targeting (ES6 vs. ES5, etc.)
    
    sandbox
      .init(frame, `
    <script>
      subscribe('ping', function (payload) {
        console.log('ping', payload);
        emit({ type: 'pong', payload: { outer: 'payload' } });
      });
    </script>`)
      .then(({ node, emit, subscribe, onDestroy }) => {
        subscribe('pong', (payload) => {
          console.log('pong', payload);
      });
    
      emit({ type: 'ping', payload: { inner: 'payload' } });
    })

Note

The init method returns an HTMLIFrameElement. Usually this element is the original IFrame element that got passed to the method as the first argument. But under certain circumstances a clone of the original IFrame element is returned that was used internally to replace the existing (already attached) IFrame element. The reason for this behavior are several sanity checks inside the init method that are required to ensure the availability of the sandbox attribute and the compatibility with legacy browsers (like IE 10 and 11). Once the original IFrame is attached to the DOM, changes to the sandbox attribute aren't reflected anymore, so we need to replace the original IFrame with a slightly modified clone in order to make the whole @component-sandbox/core work.

TL;DR

In order to prevent subtle issues that in the worst case only occur in certain legacy browsers, the safest way is to always store and use the HTMLIFrameElement that gets passed as node property once the init method's returned promise gets resolved.

API

Frame Creation

/**
 * attributes: Object => List of attributes to be added to the IFrame
 * styles:     Object => List of inline styles to be added to the IFrame
 * 
 * returns IFrame node
 */
sandbox.create(attributes?, styles?);

Sandbox initialization

/**
 * iframe:  Node => IFrame node that is already appended to the document
 * content: string | { head?: string, body?: string } => HTML markup injected into the sandbox body and/or head
 * options: { baseUrl: string = '.' } => Custom meta attributes for the sandbox
 * returns Promise<{ node, subscribe, emit, onDestroy }>
 */
sandbox
  .init(iframe, content?, options?)
  .then(({ node, subscribe, emit, onDestroy }) => {});

Messaging

To communicate between the parent and the sandbox a messaging API is available. The subscribe and emit methods to communicate from the parent to the sandbox are available in the resolved sandbox.init call. Inside the sandbox the emit and subscribe methods are available on the global scope.

emit(
  message: {
    type: string;
    payload?: any;
    source?: any;
    transfer?: Transferable | Transferable[];
  },
  callback?: (payload?: any) => void
);
subscribe(
  type: string,
  callback: (
    payload: any,
    context?: {
      source?: any,
      transfer?: Transferable[],
      callback?: string
    }
  ) => void,
  source?: any
);

Default Events

Some default events for different use cases are available on the parent API:

Frame Resizing

This event is called after the IFrame resized. Passes in a message data object containing the height, width and the type of the event that triggered the IFrame to resize.

/**
 * width:  number => The new width of the IFrame
 * height: number => The new height of the IFrame
 * type:   string => The type of event that triggered the IFrame to resize
 */
subscribe('SBX:RESIZE', ({ width, height, type }) => { ... });

Frame Focus

The two focus-related events SBX:FOCUS and SBX:BLUR are called whenever the IFrame either received or lost focus. These events mainly exist for convenience reasons to enable implementors to enhance and align their user experience and accessibility across different browsers. IE and Firefox for instance provide extra tab stops for IFrames, while Chrome and Safari do not. These extra tab stops break the user's expected tab order though.

SBX:FOCUS

Utilizing the SBX:FOCUS event enables implementors to listen to these extra tab stops and instead delegate the focus immediately to a certain focusable element inside the IFrame.

In order to be able to distinguish between a normal focus event and one that most-likely marks such an extra tab stop, the payload contains a boolean property isDocumentElement which is set to true if the currently focused element is the IFrame's documentElement.

/**
 * isDocumentElement: boolean => Whether the currently focused element is the IFrame's documentElement
 */
subscribe('SBX:FOCUS', ({ isDocumentElement }) => { ... });
SBX:BLUR

This event is the counterpart of the SBX:FOCUS event. It can be used to explicitly get notified when an IFrame loses its focus.

subscribe('SBX:BLUR', () => { ... });

Callback Functions

Communication between a sandbox and its host is an asynchronous process. If you have a scenario where a sandbox wants to request the host to do a certain thing on its behalf and then at a later point in time get informed about the result of that request, this process involves at least these 4 steps:

  1. Sandbox emits an event with the intention to start a certain process
    emit({ type: 'process:start' });
  2. Host subscribes to that event and starts the actual processing
    subscribe('process:start', () => { ... });
  3. Host emits the result of the processing via an event
    emit({ type: 'process:end', payload: { ... } });
  4. Sandbox subscribes to that event and uses the result
    subscribe('process:end', (payload) => { ... });

This 4-step process (including 2 disconnected steps on the sandbox's side) can be simplified from the sandbox's perspective via the use of callback functions:

emit({ type: 'process' }, (payload) => { ... });

In that case, all the sandbox has to do is sending a request to the host along with a callback function in order to be informed about the host's response later. This saves the sandbox implementors from having to subscribe to some kind of response event. Under the hood this whole process still needs to be a complex multi-step asynchronous scenario, yet from a sandbox implementor's perspective the handling of that whole process as well as the related code becomes way more convenient.

Using callback functions along with a MessageChannel interface relies on some syntactical sugar on the @component-sandbox/core's end, because it's not possible by design to pass functions as part of messages via Window.postMessage or MessagePort.postMessage between different browsing contexts.

What @component-sandbox/core under the hood does is:

  1. Take a given callback function which is passed as a second parameter in the emit method
  2. Generate a unique ID for the callback function
  3. Store the callback function inside a key-value container on the sandbox's side using the generated ID as the key (so that it can be accessed easily)
  4. Pass the generated ID to the host so that the host can store it
  5. Provide a listener for the system event SBX:CBK which the host can emit to request the invocation of a certain stored callback function along with a given payload
emit({
  type: 'SBX:CBK',
  callback: '${callbackId}',
  payload: { ... }
});

Error Handling

The SBX:ERROR event essentially passes on the error information as they are caught by a window.onerror handler that gets registered on the IFrame's contentWindow.

By default the @component-sandbox/core creates or prepares an IFrame element in a way that it behaves like a cross-origin IFrame, as the default sandbox attribute that gets applied to the IFrame element lacks the allow-same-origin restriction lift.

So it's important to know that when an error occurs in a script, loaded from a different origin, the details of the error are not reported to prevent leaking information. Instead the error reported is simply "Script error.". This behavior can be overriden in some browsers using the crossorigin attribute on <script> and having the server send the appropriate CORS HTTP response headers. A workaround is to isolate "Script error." and handle it knowing that the error detail is only viewable in the browser console and not accessible via JavaScript.

/**
 * msg:      string => The error message
 * url:      string => URL of the script where the error was raised
 * lineNo:   number => Line number where error was raised
 * columnNo: number => Column number for the line where the error occurred
 * error:    Object => The error object
 */
subscribe('SBX:ERROR', ({ msg, url, lineNo, columnNo, error }) => { ... });

Component Destruction & Cleanup

Even though the underlying iFrameResizer library is kind of smart and aware of situations where the sandboxed IFrame element gets removed from the host, you cannot fully rely on its built-in detection mechanisms in all possible (dynamic) scenarios. In order to provide an explicit way to tell the sandbox that it's about to be destroyed, an onDestroy callback method can be extracted from the sandbox.init's promise response.

The onDestroy callback method enables a consumer to explicitly inform the @component-sandbox/core that a certain sandbox is about to be destroyed now. Internally this helps the library to deregister certain listeners the iFrameResizer library adds to both the host and the sandboxed IFrame instance and therefore free up resources and prevent possible memory leaks.

import sandbox from '@component-sandbox/core';

class MyComponent {
  private iframeEl: HTMLElement;

  private sandboxApi: { onDestroy: () => void } = {};

  onInit(): void {
    sandbox
      .init(this.iframeEl)
      .then(({ onDestroy }) => {
        this.sandboxApi.onDestroy = onDestroy;
      });
  }

  onDestroy(): void {
    this.sandboxApi.onDestroy();
  }
}