Skip to content

Commit

Permalink
Defend against font-face side channeling attack, and refactor (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
weizman authored Jan 7, 2024
1 parent ae466ed commit e2f2dd4
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 106 deletions.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ While the `ShadowDom` API is not officially endorsed as a security tool by its c

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

### `ShadowDom` security gaps
### Threats

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

#### 1. Injection

Expand All @@ -154,7 +154,7 @@ To prevent this possibility, **`LavaDome`** does not accept DOM nodes at all int

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. [window.find()](https://blog.ankursundara.com/shadow-dom/#introducing-windowfind-and-text-selections)
#### 2. Findability ([window.find()](https://blog.ankursundara.com/shadow-dom/#introducing-windowfind-and-text-selections))

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`.

Expand Down Expand Up @@ -222,7 +222,7 @@ To defend against this attack vector, **`LavaDome`** removes all style attribute

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

#### 3. Selectability
#### 3. Selectability ([getSelection](https://developer.mozilla.org/en-US/docs/Web/API/Window/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.

Expand All @@ -234,9 +234,17 @@ As a countermeasure, **`LavaDome`** stores each character of the secret in its o

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 (see [#15](https://github.com/LavaMoat/LavaDome/issues/15#issuecomment-1873375440)), but only in Firefox.
> NOTICE: This technique was proven to be possible against LavaDome (see [#15](https://github.com/LavaMoat/LavaDome/issues/15#issuecomment-1873375440)), but only in Firefox.
### Defensive coding
#### 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](https://github.com/LavaMoat/LavaDome/issues/16#issue-2067572697))
#### 6. Defensive coding

A secure solution requires defensive coding practices.

Expand Down
8 changes: 3 additions & 5 deletions packages/core/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@
<script type="module">
import {LavaDome as LavaDomeCore} from "../src/index.mjs";
top.start = (function() {
const secret =
(Math.random() + 1).toString(36).substring(7) +
(Math.random() + 1).toString(36).substring(7) +
(Math.random() + 1).toString(36).substring(7) +
(Math.random() + 1).toString(36).substring(7);
const blobURL = URL.createObjectURL(new Blob());
const secret = blobURL.split('/')[3].split('-').join('');
URL.revokeObjectURL(blobURL);

return function start(root) {
root.innerHTML = '';
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/element.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

import {
Array,
createElement,
entries, from,
join, keys, map,
parseInt, random,
setAttribute,
textContentSet,
toFixed,
toUpperCase,
} from './native.mjs';

const letters = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
const symbols = '!@#$%^&*()?.;:"\'[]{}+=-_/';
const alphanumeric = letters + digits;
const all = letters + toUpperCase(letters) + digits + symbols;

const randChar = (f, n) => f[parseInt(toFixed(random() * n))];
const rand = len => randChar(letters, 26) +
join(map(from(keys(Array(len))), () => randChar(alphanumeric, 36)), '');

// secure and generic creator for elements with specific characteristics
function creator(style, tag, text = '') {
// turn style object to string asap to prevent later pollution attack attempts
style = join(map(entries(style), ([k,v]) => `${k}: ${v} !important`), '; ');
return function () {
const node = createElement(document, tag());
setAttribute(node, 'style', style);
textContentSet(node, text);
return node;
};
}

const invoker = creator => () => creator();

// an element that is hard to find/select
export const unselectable = invoker(creator({
// makes element uneditable to prevent document.execCommand HTML injection attacks
'-webkit-user-modify': 'unset',
// makes element unselectable to prevent getSelection attacks
'-webkit-user-select': 'none', 'user-select': 'none',
}, () => rand(7)));

// an element that includes all possible chars to distract side channel leaks attempts
export const distraction = invoker(creator({
// place element so that it isn't intractable nor viewable
// for the user, but still a distraction for attackers
'top': '-10px', 'right': '-10px', 'position': 'fixed',
// font-size smaller than 1px fails to be a distraction on Firefox
'font-size': '1px',
}, () => 'span', all));
135 changes: 40 additions & 95 deletions packages/core/src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,110 +1,55 @@
"use strict";

const {
Object,
Array,
Function,
Math,
parseInt,
Map,
} = window;

const {
getOwnPropertyDescriptor,
entries,
} = Object;

const {
random,
} = Math;

const css = Object.create(null);
css['-webkit-user-modify'] = 'unset';
css['user-select'] = 'none';

const opts = Object.create(null);
opts.mode = 'closed';

const letters = 'abcdefghijklmnopqrstuvwxyz';
const nums = '0123456789';

// p stands for primordial
function p(obj, prop, accessor, type = 'function') {
const desc = getOwnPropertyDescriptor(obj, prop);
switch (type) {
case 'function':
return Function.prototype.call.bind(desc[accessor]);
break;
}
}

function generateRandomString(len = 1) {
let tag = letters[parseInt(random() * (26))];
for (let i = 1; i < len; i++) {
const r = parseInt(random() * (36));
tag += (letters+nums)[r];
}
return tag;
}

const attachShadow = p(Element.prototype, 'attachShadow', 'value');
const createElement = p(Document.prototype, 'createElement', 'value');
const appendChild = p(Node.prototype, 'appendChild', 'value');
const textContentSet = p(Node.prototype, 'textContent', 'set');
const innerHTMLSet = p(ShadowRoot.prototype, 'innerHTML', 'set');
const setAttribute = p(Element.prototype, 'setAttribute', 'value');
const map = p(Array.prototype, 'map', 'value');
const split = p(String.prototype, 'split', 'value');
const join = p(Array.prototype, 'join', 'value');
const get = p(Map.prototype, 'get', 'value');
const set = p(Map.prototype, 'set', 'value');
'use strict';

import {
Map, Error,
defineProperties,
from, stringify,
attachShadow,
createElement,
appendChild,
textContentSet,
map, at, get, set,
} from './native.mjs';
import {distraction, unselectable} from './element.mjs';

const shadows = new Map();

export function LavaDome(root) {
let host = root, inner = null;

this.text = text; this.char = char;
export function LavaDome(host) {
// make exported API tamper-proof
defineProperties(this, {text: {value: text}});

// cache shadows for efficient reuse
let shadow = get(shadows, host);
if (!shadow) {
shadow = attachShadow(host, opts);
shadow = attachShadow(host, {mode:'closed'});
set(shadows, host, shadow);
}

empty();

function empty() {
innerHTMLSet(shadow, '');
}
// child of the shadow, where the secret is set, must be unselectable
const child = unselectable();
appendChild(shadow, child);

function reset() {
empty();
const tag = generateRandomString(7);
const style = join(map(entries(css), ([k,v]) => `${k}: ${v} !important`), '; ');
inner = createElement(document, tag);
setAttribute(inner, 'style', style);
}
function text(text) {
if (typeof text !== 'string') {
throw new Error(
`LavaDome: first argument must be a string, instead got ${stringify(text)}`);
}

function init() {
reset();
appendChild(shadow, inner);
}
// check if text is a single char and if so, either is part of a longer secret
// which is protected by the parent LavaDome, or simply a single char provided by
// consumer either way - not worth attempting to secure
if (at(from(text), 1) === undefined) {
return textContentSet(child, text);
}

function char(char) {
init();
textContentSet(inner, char);
}
// place each char of the secret in its own LavaDome protection instance
map(from(text), char => {
const span = createElement(document, 'span');
new LavaDome(span).text(char);
appendChild(child, span);
});

function text(text) {
init();
const chars = split(text, '');
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const s = createElement(document, 'span');
const ld = new LavaDome(s);
ld.char(char);
appendChild(inner, s);
}
// add a distraction against side channel leaks attack attempts
appendChild(child, distraction());
}
}
52 changes: 52 additions & 0 deletions packages/core/src/native.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

const {
Object, Array,
Function, Math,
parseInt, Map,
Error, JSON,
} = window;
const {
defineProperties,
getOwnPropertyDescriptor,
entries,
} = Object;
const { from } = Array;
const {random } = Math;
const { stringify } = JSON;

// native generation util
const n = (obj, prop, accessor) =>
Function.prototype.call.bind(getOwnPropertyDescriptor(obj, prop)[accessor]);

export const attachShadow = n(Element.prototype, 'attachShadow', 'value');
export const createElement = n(Document.prototype, 'createElement', 'value');
export const appendChild = n(Node.prototype, 'appendChild', 'value');
export const textContentSet = n(Node.prototype, 'textContent', 'set');
export const setAttribute = n(Element.prototype, 'setAttribute', 'value');
export const toUpperCase = n(String.prototype, 'toUpperCase', 'value');
export const map = n(Array.prototype, 'map', 'value');
export const join = n(Array.prototype, 'join', 'value');
export const keys = n(Array.prototype, 'keys', 'value');
export const at = n(Array.prototype, 'at', 'value');
export const get = n(Map.prototype, 'get', 'value');
export const set = n(Map.prototype, 'set', 'value');
export const toFixed = n(Number.prototype, 'toFixed', 'value')

export {
// window
Object, Array,
Function, Math,
parseInt, Map,
Error, JSON,
// Object
defineProperties,
getOwnPropertyDescriptor,
entries,
// Array
from,
// Math
random,
// JSON
stringify,
}

0 comments on commit e2f2dd4

Please sign in to comment.