Building a mediated DOM interface for guest code
Instead of handing guest code the real document, a host can hand it a small object with a few methods and nothing else. This guide builds one in about forty lines and shows both what the wrapper prevents and one way it still leaks.
The idea
ADsafe's DOM documentation describes a dom object that is the root of the widget's subtree, a q query method that returns a bunch of wrapped nodes, and bunch methods that act on them. The guest never holds a raw element. Every capability it has is a method the host chose to write.
The example below is original code in the same spirit, not a copy of ADsafe's API. It is meant to show the structure: validate inputs, keep raw nodes in a closure, and never pass a raw node or event to guest code.
A small wrapper
The host calls makeView with the element that holds the widget. The guest receives only the returned view object.
function makeView(root) {
var SELECTOR = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)?$/;
var CLASS_NAME = /^[a-z][a-z0-9-]*$/;
var EVENTS = { click: true, input: true };
function wrap(nodes) {
var bunch = {
count: function () { return nodes.length; },
text: function (value) {
nodes.forEach(function (n) { n.textContent = String(value); });
return bunch;
},
addClass: function (name) {
if (!CLASS_NAME.test(name)) { throw new Error("bad class name"); }
nodes.forEach(function (n) { n.classList.add(name); });
return bunch;
},
on: function (type, fn) {
if (!EVENTS[type] || typeof fn !== "function") {
throw new Error("event not allowed");
}
nodes.forEach(function (n) {
n.addEventListener(type, function () { fn(wrap([n])); });
});
return bunch;
}
};
return Object.freeze(bunch);
}
return Object.freeze({
query: function (selector) {
if (!SELECTOR.test(selector)) { throw new Error("bad selector"); }
return wrap(Array.prototype.slice.call(root.querySelectorAll(selector)));
}
});
}Three decisions carry most of the design. Selectors must match a strict pattern, so descendant combinators, ids and pseudo-classes are rejected. Because the search starts at root.querySelectorAll, results are limited to that subtree. The event callback receives another wrapper, not the event, because an event object leads to target and from there to the rest of the document.
Using it
var view = makeView(document.getElementById("widget"));
view.query("button").on("click", function (button) {
view.query("p.out").value("clicked " + button.count());
});A page that contains a widget element and some unrelated host content behaves as follows. These results come from running this exact code in headless Chrome:
- Clicking the button changes the widget's paragraph to
clicked 1. view.query("p.secret")finds nothing, because the host paragraph with that class sits outside the widget element.- Selectors such as
#outside,p, div,div > pandp:first-childthrow an error. - Registering a
mouseoverhandler throws, because onlyclickandinputare on the list. addClass("x y")throws, because the name fails the pattern.
Where it still leaks
The same test page also runs this line, written the way a hostile guest might:
var leaked = view.query.constructor("return document")();
// leaked === document -> trueFreezing the object did not help. Every function inherits a constructor property that points to Function, and Function can build code that runs in the global scope. This is the reason the ADsafe documentation bans the name constructor and the eval family: a wrapper that is correct as an API is bypassed if guest code can use the full language. A wrapper therefore needs one of two companions: a checker that rejects such constructs before the code runs, or a real boundary such as a separate frame. A policy without 'unsafe-eval' would also block this particular call, but it would not make the wrapper a boundary.
Reviewing a wrapper
- List every method. For each one, write down what it returns and what the guest can reach from that value.
- Check that no method returns, stores or passes to a callback a raw node, event, window or other host object.
- Check that strings used as selectors, property names or attribute names go through a whitelist or pattern.
- Test attempts to leave the subtree, not only the allowed operations.
ADsafe is a historic project. This site is an independent reference, is not the official ADsafe site, and is not endorsed by the author of ADsafe. It does not offer ADsafe as a currently reviewed security boundary. The wrapper above is a teaching example, not a hardened library. Compare it with the reasons for the language restrictions and the original DOM interface page.
Primary sources
- ADsafe DOM interface documentation (dom.html)
- MDN: Element.querySelectorAll()
- MDN: Function() constructor
Related guides
- Why eval, this and global names are restricted in guest code
- Reading the ADsafe widget template, part by part
Independent educational explanation. Read alongside the cited sources; this page does not certify the historical runtime as a current security boundary.
