/LavaDome

Secure DOM trees isolation and encapsulation leveraging ShadowDOM

Primary LanguageJavaScriptMIT LicenseMIT

LavaDome 🌋️

~ A new LavaMoat tool for DOM nodes secured Encapsulation ~

⚠️ EXPERIMENTAL [WIP] - USE AT YOUR OWN RISK (learn more)

Take a crack at LavaDome - visit the demo app, open the console, and do whatever in your power to steal the secret from within the LavaDome instance (report your success)

Preview (click to expand)
LavaDome DEMO

Motivation

Under today's web standards, there is no established way to selectively isolate DOM subtrees in a secured manner. In other words, we can't control access to sections of the DOM by granting access for some parties while blocking access for others if they share the same JavaScript execution environment.

We live in a world where we can no longer trust the code in our own apps, and same-origin execution does not guarantee safety. To secure secrets in the frontend, we must be able to present content to the user while ensuring that it cannot be compromised by JavaScript code running under the same origin.

Example

One use case for such a feature is MetaMask's "show private key" toggle, which exports the private key into plaintext upon user request. (click to expand)
Show private key feature by MetaMask

Currently, this sensitive content is simply attached to the DOM once it is exported, making it fully accessible to all entities running in the same app. That is, sections of the code that shouldn't have access to the private key could easily extract it in plaintext, so long as the malicious code has access to the DOM.

But rest assured. We believe this is a solvable problem 👇

Usage

LavaDome currently supports Vanilla JavaScript and React (with more on the way)

import { LavaDome as LavaDomeJavaScript } from '@lavamoat/lavadome-javascript';

const root = document.getElementById('root');
const lavadome = new LavaDomeJavaScript(root);
lavadome.text(secret);
import { LavaDome as LavaDomeReact, toLavaDomeToken } from '@lavamoat/lavadome-react';

function Secret({ text }) {
    return <LavaDomeReact text={toLavaDomeToken(text)} />
}

API

In addition to the root node, all constructors accept optional options 2nd argument:

// javascript
new LavaDomeJavaScript(root, {
    // boolean
    unsafeOpenModeShadow: false,
});

// react
function Secret({ text }) {
    return <LavaDomeReact
        text={toLavaDomeToken(text)}
        // boolean
        unsafeOpenModeShadow={false}
    />
}

Testing

Integrating LavaDome could be tricky in context of testing it, because since LavaDome does a good job in hiding the secret, it hides it pretty well from your tests too!

To successfully integrate LavaDome into your testing environment, you might need some help from LavaDomeDebug which is exported by @lavamoat/lavadome-core:

// IMPORT/USE FOR TESTING/DEBUGGING PURPOSES ONLY - NEVER IN PRODUCTION!
import { LavaDomeDebug } from '@lavamoat/lavadome-core';

Here are some of the debugging util methods LavaDomeDebug exports that can assist you in testing LavaDome based components:

getTextByRoot()

Given a LavaDome attached root, getTextByRoot() will recursively extract and reconstruct the inner secret. In order to allow that, the LavaDome instance must be initiated originally with the UNSAFE option @unsafeOpenModeShadow, which makes LavaDome's inner shadows accessible from outside.

Naturally, this is UNSAFE and leaves LavaDome fully vulnerable, but makes sense to use for testing/debugging purposes only - makes sure to never enable this option in production!

new LavaDomeJavaScript(root, {
    unsafeOpenModeShadow: isThisTestingEnv, // boolean
}).text('123456');
LavaDomeDebug.getTextByRoot(root) === '123456'; // true

stripDistractionFromText()

When using web drivers for testing and instructing those to extract the inner text of a LavaDome instance root, they will return a string containing both the secret and LavaDomes distraction text.

The distraction text is important for security (see Security(side-channeling)), but makes web driver extract characters that aren't really part of the secret.

To solve that, given the text obtained by the web driver, stripDistractionFromText() will strip the distraction text from it, leaving only the exact string your tests expect to find.

Don't worry about the distraction text, it will never be visible/intractable to the user in your app, but it must exist for security reasons

new LavaDomeJavaScript(root).text('123456');
const element = await driver.findElement('#ROOT'); // driver
const text = await element.getText(); // driver
LavaDomeDebug.stripDistractionFromText(text) === '123456'; // true

Develop

To set up a local development build of LavaDome, clone this repo and run one of the following commands:

npm install && npm install --global serve
yarn install && yarn global add serve

Solution

The ShadowDom Web API enables us to isolate and encapsulate DOM nodes. Although it's not designed as a security feature, ShadowDom works well for isolating DOM subtrees from JavaScript and CSS that's running elsewhere in the page.

LavaDome's basic approach is to leverage ShadowDom, while carefully addressing its potential security gaps.

LavaDome is intended to be a security tool in the LavaMoat toolbox for implementing frontend-only components that exclusively allow interactions with the user and trusted code, while blocking access attempts by untrusted JavaScript and CSS code in the app.

Shout-out to @arxenix for their research into ShadowDom security, which provided the basis for major security improvements implemented in LavaDome.

Goals

The LavaDome project follows the following core principles:

Secure

Our top priority is providing airtight security. We have wrapped the ShadowDom API with advanced security properties to make it safe for use when presenting sensitive info.

Visit Security to learn more about this effort.

DX

We strive to provide a streamlined developer experience. To this end, we will:

  1. Support as many popular frameworks (React, Angular, etc) as possible;
  2. Make the API easy and simple to use.

Read-mode only

At this stage, we do not plan to support write-mode, meaning LavaDome will only accept plaintext content for protection, and nothing more complex than that.

This is because supporting write-mode will require implementing an intractable isolated DOM, which introduces multiple security complications that we're not yet ready to face at this point, such as:

  1. Event listeners security - prevent outer code from intercepting input that is destined for LavaDome inner nodes.
  2. Overlay security - prevent malicious code from laying a phishing DOM on LavaDome to make the user serve sensitive input to the wrong entity.

Design

The design complexity of this project isn't high. However, satisfying the combined requirements of the security principles it implements is a non-trivial task (see Security).

LavaDome consists of the following packages:

Implements the basic API layer that mediates the communication between the consumer and the protected isolated component. The API aspires to allow as much external manipulation of the isolated component as possible without providing actual DOM nodes from within it to anyone - not even the consumer of LavaDome - to maintain the highest security level possible.

In addition, it takes the responsibility of implementing all necessary security hardening to make ShadowDom feature usage truly secure in contrast to its native nature of not being a security feature by default (see Security).

Remember: the core package is not to be used for production purposes!

Export functionalities for developers to consume LavaDome however they prefer, whether by JavaScript or as a React component (or any other platform - ask away!)

NOTE: Delivering LavaDome support for frameworks integrates third party code that we do not control, which causes "security blank spots".

Please read the Security section to learn how to remain as safe as possible when using LavaDome with third-party frameworks.

Security

If you plan on using LavaDome for a project, here are the security aspects to be aware of:

ShadowDom vs iframe

Again, this is still an experimental project, but we did put some thought into this decision. A natural alternative to using the ShadowDom is leveraging cross-origin iframes. Infiltrating a cross-origin iframe is impossible, and it is recognized as a security critical mechanism by W3C spec. This means that if a breach somehow happens, it is treated as a security vulnerability and fixed by browser vendors with urgency.

The downside to this approach, however, is that integrating an iframe-based solution is significantly more difficult, in terms of UI/UX/DX, especially as a tool aimed at mass adoption.

LavaDome needs to provide a smooth and natural developer experience while facilitating the secure integration of encapsulated shadow DOM nodes within the host DOM tree, and ShadowDom is a DOM-oriented API built precisely for that purpose. This made it better-suited for our goals.

While the ShadowDom API is not officially endorsed as a security tool by its creators, its implementation is highly secure, and it does not leak any encapsulated information from within the shadow DOM tree except under very specific scenarios.

We believe that by carefully addressing those very scenarios, ShadowDom can be augmented into a secured DOM encapsulation API (worth a shot).

Threats

It's important to address the current security threats that exist with ShadowDom based solution such as LavaDome.

1. Injection

Developers might provide LavaDome with HTML/JS/CSS content that, when loaded, can accidentally or intentionally leak DOM nodes from within the ShadowDom, for example by dynamically adding JavaScript code at runtime.

To prevent this possibility, LavaDome does not accept DOM nodes at all into the shadow DOM tree, and only supports encapsulating plain text. This lets us avoid having to grapple with the security issues inherent in trusting user-supplied HTML/JS/CSS content.

We'd love to revisit this decision in the future as we research a stable and secure means of supporting DOM node and subtree input.

2. Findability (window.find())

This API allows developers to find and extract DOM nodes by searching for text that they contain. This is the only API that has so far been known to successfully leak DOM nodes from within a ShadowDom.

In Firefox, after finding the text, one can use getSelection() API to leak DOM nodes from within the `ShadowDom`, thus compromising the whole idea: (click to expand)
// defender
const secret = 'AN UNPREDICTABLE SECRET';
const opts = { mode:'closed' };
const root = document.body.firstElementChild.firstElementChild;
const p = document.createElement('p');
const shadow = root.attachShadow(opts);
shadow.append(p);
p.innerText = 'Secret is: ' + secret;

// attacker
setTimeout(() => {
    find('Secret is:'); // assuming the Shadow includes predictable text
    console.log('stolen secret: ', getSelection().anchorNode.textContent);
});
`ShadowDom` bypass Firefox

To defend against this attack, the LavaDome consumer must not pass predictable content to the LavaDome API. While this might sound obvious, developers could easily be tempted to pass LavaDome an input that looks something like The secret is: ldsjf9304rjdkn, which would fully compromise the security of LavaDome. Even though the ldsjf9304rjdkn part is unguessable, the fixed phrase "The secret is: " could be exploited to reveal the secret, especially if it was previously exposed in the DOM.

Therefore, when using LavaDome, developers MUST only pass 100% unpredictable text as input.

Chromium is secure against the above attack. However, if a selected DOM node within the `ShadowDom` is content-editable, attackers can leverage document.execCommand('insertHTML', ...) to achieve arbitrary code execution in the inner scope of the `ShadowDom`, and use that to access the encapsulated DOM nodes. (click to expand)
// defender
const secret = 'AN UNPREDICTABLE SECRET';
const opts = { mode:'closed' };
const root = document.body.firstElementChild.firstElementChild;
const div = document.createElement('div');
const shadow = root.attachShadow(opts);
shadow.append(div);
const p = document.createElement('p');
p.innerText = 'Secret is: ' + secret;
div.appendChild(p);
div.setAttribute('contenteditable', 'true');

// attacker
setTimeout(() => {
    console.log(1, 'stolen secret:');
    const bypass = '<audio/src/onerror=console.log(2,this.nextSibling.innerHTML)>';
    find('Secret is:'); // assuming the Shadow includes predictable text
    // assuming the found node is contenteditable=true
    document.execCommand('insertHTML', false, bypass);
});
`ShadowDom` bypass Chromium

To defend against this attack vector, LavaDome removes all style attributes from its custom elements using the highest priority style attribute possible (-webkit-user-modify: unset;). This ensures that its elements are not vulnerable to injection of malicious external CSS that applies the -webkit-user-modify:read-write attribute, which would make ShadowDom elements contenteditable.

The second technique of using contenteditable as an attribute isn't currently relevant as LavaDome does not support accepting DOM nodes.

3. Selectability (getSelection)

The attack vectors above aren't so useful if getSelection is mitigated. By making the text contained in LavaDome non-selectable, we harden the security against possible injection as demonstrated above. This works well in Chromium, but we are working out some issues with Firefox.

4. Secret splitting

If an attacker manages to guess a subset of the secret, they can compromise the entire secret (assuming getSelection captures scoped nodes like in Firefox). This is because searching for the subset will leak the text node that includes that subset of the secret, giving the attacker access to the entire secret.

As a countermeasure, LavaDome stores each character of the secret in its own ShadowDom, ensuring that compromising a subset of the secret will not lead to the rest being compromised as well. This safeguard has the additional benefit of making it exponentially more difficult to attackers to leak the whole secret the longer it is and the more character options it potentially includes.

A breach is still possible, but only if the attacker brute-forces all possible characters one by one, leaks all of the shadows they find, and then synchronously reorders all of the shadows correctly to align with their respective positions within the LavaDome main host.

NOTICE: This technique was proven to be possible against LavaDome (see #15), but only in Firefox.

5. Side channeling

Another well known attack is to leak contents of ShadowDOMs using inheritable CSS properties such as @font-face to a remote server, character by character.

To address that, LavaDome adds to the parent Shadow all characters possible, so that such leaking attempt is confused when finding all possible characters, leaving this attack useless.

NOTICE: This technique was proven to be possible against LavaDome (see #16)

6. Defensive coding

A secure solution requires defensive coding practices.

  • To this end, all of the native APIs we use are cached for internal usage, to prevent attackers from reconfiguring global APIs to sabotage the execution flow of LavaDome.

  • If you observe unconventional stylistic choices in the source code, there's a good chance they were informed by defensive coding principles.

  • It is crucial to include LavaDome in the app before any scripts you don't trust, and preferably before ALL scripts!

  • When using the framework versions of LavaDome, you should assume that these frameworks are not defensively written, and that the native APIs use are not safe from malicious interference. Be warned that the security of external code is outside of LavaDome's control.

Therefore, we recommend always integrating such security solutions with the SES technology developed by @agoric. This is a security practice followed at LavaMoat and MetaMask.

7. React internals processing leakage

Another thing to worry about (specifically in context of React) is the fact that input provided to React components is being actively leaked by it to the global object, thus making it up for grabs for untrusted entities running in the app (which undermines LavaDome's goal completely).

To balance our intention to support React with how we can't trust it with our secret, LavaDomeReact package exports some minimal (yet safe) functionality to exchange the secret with a special token before passing it on to React, where the only entity that can exchange that token back to the secret is LavaDome itself.

While powerful, this unfortunately requires React users to actively perform the exchange before passing the secret to LavaDomeReact.

If anything other than a well known token is received by the user, a LavaDome-generated exception is thrown, to force developers to use LavaDomeReact safely.

Disclaimer

If you read everything above, you should have a good sense of why LavaDome is still very experimental. Making a non-security feature secure is inherently risky, but as this problem space has no good existing solutions, we feel that this attempt represents a step in the right direction.

We still recommend using LavaDome, as it represents an unambiguous improvement compared to relying only on current web standards. Just remember that our solution will make your code "safer," but not "safe."

Additionally, please remember: LavaDome helps you bring a secret to DOM securely. Whether the secret was breached or not before being passed to LavaDome is out of LavaDome's scope.

This means it is your responsibility making sure the secret is safe up until the point you share it with LavaDome.

The best way to achieve that is by running under a locked down environment using SES / LavaMoat.