โ ๏ธ 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)
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.
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)
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 ๐
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)} />
}
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}
/>
}
Due to web core limitations, in order to integrate LavaDome securely, there are a few things to be aware of that require some active effort by the integrating developer:
LavaDome, like any other JavaScript security software, is always vulnerable to code running before it does.
This means that except for code we absolutely trust, LavaDome must be the first piece of code to load in the web application program.
While this doesn't mean that the developer must make use of it immediately (but rather only when they need to), they do however must include the program as soon as possible.
In order to do so correctly (safely), it must be the first import/require declaration in the entire program:
import '@lavamoat/lavadome-react';
import 'other-stuff';
console.log('Program starts here');
That way we guarantee LavaDome gets to prepare itself for safe usage.
Note that this applies similarly to the rest of the LavaDome packages and not just @lavamoat/lavadome-react
(so importing more than one of them is unnecessary).
Jump over to Security(defensive-coding) to learn more.
Due to side channeling attacks and web limitations, importing remote fonts can be a successful technique against LavaDome. Since embedded in the realms of CSS, addressing this issue via LavaDome isn't possible currently.
Luckily, this can be effectively addressed using CSP's font-src
directive.
To mitigate this form of attack, make sure your web app does not allow fetching fonts from unknown servers.
Jump over to Security(side-channeling) to learn more.
Text provided to LavaDome by the developer must be 100% unpredictable, otherwise can be attacked and leaked.
So if your app should present "your key is 234789"
, this means your DOM structure should be:
<span>your key is <lavadome>234789</lavadome> </span>
and must not be:
<span> <lavadome>your key is 234789</lavadome> </span>
Jump over to Security(findability) to learn more.
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:
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
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 LavaDome
s 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
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
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 inLavaDome
.
The LavaDome
project follows the following core principles:
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.
We strive to provide a streamlined developer experience. To this end, we will:
- Support as many popular frameworks (React, Angular, etc) as possible;
- Make the API easy and simple to use.
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:
- Event listeners security - prevent outer code from intercepting input that is destined for LavaDome inner nodes.
- Overlay security - prevent malicious code from laying a phishing DOM on
LavaDome
to make the user serve sensitive input to the wrong entity.
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!
JavaScript / React / etc
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.
If you plan on using LavaDome
for a project, here are the security aspects to be aware of:
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 iframe
s. 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).
It's important to address the current security threats that exist with ShadowDom
based solution such as LavaDome
.
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.
Read @arxenix's research to learn more about this technique.
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.
The find() 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);
});
Read @arxenix's research to learn more about this technique.
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);
});
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.
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.
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.
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.
Consider the following attack research documented by @masatokinugawa.
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 (see #16).
Of course, side channeling comes in many forms, some harder to address, such as @securityMB's research where he uses ligature fonts (exploit by @masatokinugawa @ #40).
To address that, developers adopting LavaDome are expected to dictate a strict font-src
CSP policy to make sure leakage by fonts isn't possible to remote uncontrollable servers.
Worth noting that this (theoretically) won't be useful in Safari where this attack can be carried using local SVG to form the fonts, thus allowing attackers to remain independent of CSP (see WIP @ #40 (comment)).
Another great example for side channeling attacks - this time not using fonts - is by leveraging text fragments (see @masatokinugawa exploit).
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 ofLavaDome
'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.
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).
Refer to naugtur's discovery to learn more.
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.
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.