The Security Model: Shared Intrinsics, Pollution, Membranes, and Side Channels
Every script in a realm shares one `Object.prototype`. Everything else about JavaScript security follows from that sentence.
JavaScript has no ambient authority model, no capability checks, and one mutable set of intrinsics per realm. Prototype pollution, supply-chain attacks, and sandbox escapes are all consequences of that design; membranes, frozen intrinsics (Hardened JavaScript), realms, and Workers are the mechanisms the language offers to rebuild boundaries. Separately, the hardware leaks: Spectre turned any high-resolution timer into a read primitive, and the specification and the browsers changed shape around it. This chapter treats both layers with their mechanisms exposed.
In this chapter
- One realm, one set of intrinsics, no authority boundaries
- Prototype pollution: `[[Set]]` on a key named `__proto__`
- Frozen intrinsics: Hardened JavaScript
- Membranes: controlled reachability with proxies
- `eval`, `Function`, and the host's veto
- Spectre: when the timer is the vulnerability
- Supply chain: the realm is the trust boundary, and `import` crosses it
One realm, one set of intrinsics, no authority boundaries
Within a realm every script, whatever its origin or trust level, sees the same Object.prototype, Array.prototype, Function.prototype, and every other intrinsic (chapter 9). Those objects are mutable by default. There is no notion in ECMA-262 of a principal, a permission, or a privileged caller; the language's only isolation primitive is reachability: code can affect exactly the objects it can reach a reference to. A script that can reach Object.prototype can change the behaviour of every object in the realm, including the ones the host's own APIs rely on. That is not a flaw in the design so much as the design: the same reachability rule that makes closures private (chapter 5) and private names unforgeable (chapter 4) is what makes the shared intrinsics a global mutable surface. Security in JavaScript therefore consists of controlling reachability: freezing what must not change, wrapping what must not be seen, and placing what must not interfere into a different realm or agent.
Prototype pollution: `[[Set]]` on a key named `__proto__`
The mechanism was set up in chapter 4: Object.prototype.__proto__ is an accessor, and an assignment target[key] = value where key === "__proto__" invokes its setter, reassigning target's prototype. A recursive merge or "deep extend" that walks attacker-controlled JSON ({"__proto__": {"isAdmin": true}}) and assigns each key onto a destination object will, at the __proto__ key, descend into `Object.prototype` itself and write isAdmin there. From then on every plain object in the realm reports isAdmin === true for a property it does not own. The same path exists through constructor.prototype ({"constructor": {"prototype": {...}}}), because obj.constructor resolves to Object via the prototype chain and Object.prototype is reachable from there. The pollution then becomes an exploit when some later code branches on a property it never set: template engines that read options from an object and evaluate them, child_process option objects (shell, env), or any if (opts.x) guard. JSON.parse itself is safe (it creates an own data property named __proto__ with CreateDataProperty, bypassing the setter); the vulnerability is entirely in code that copies with [[Set]].
// A naive deep merge, as found in many pre-2019 utility libraries.
function merge(target, src) {
for (const key in src) {
if (typeof src[key] === "object" && src[key] !== null) {
if (typeof target[key] !== "object" || target[key] === null) target[key] = {};
merge(target[key], src[key]);
} else target[key] = src[key]; // [[Set]]: hits the __proto__ setter
}
return target;
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
console.log("JSON.parse made an OWN property named __proto__:", Object.getOwnPropertyNames(payload));
console.log("before, {}.isAdmin:", ({}).isAdmin);
merge({}, payload);
console.log("after pollution, {}.isAdmin:", ({}).isAdmin, " (every object in the realm)");
delete Object.prototype.isAdmin; // undo for the rest of the probe
console.log("cleaned up:", ({}).isAdmin);
// Fix 1: never assign through [[Set]] for untrusted keys; define own properties, or skip the two dangerous keys.
function safeMerge(target, src) {
for (const key of Object.keys(src)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
if (typeof src[key] === "object" && src[key] !== null) {
if (!Object.hasOwn(target, key) || typeof target[key] !== "object") Object.defineProperty(target, key, { value: {}, writable: true, enumerable: true, configurable: true });
safeMerge(target[key], src[key]);
} else Object.defineProperty(target, key, { value: src[key], writable: true, enumerable: true, configurable: true });
}
return target;
}
safeMerge({}, payload);
console.log("safeMerge leaves the prototype alone:", ({}).isAdmin);
// Fix 2: a null-prototype destination has no __proto__ accessor to hit.
const bare = merge(Object.create(null), payload);
console.log("null-prototype target: own __proto__ key:", Object.getOwnPropertyNames(bare), "| {}.isAdmin:", ({}).isAdmin);
// Fix 3: use a Map for dynamic keys; there is no prototype to reach.
const m = new Map(Object.entries(payload));
console.log("Map-based merge is immune:", ({}).isAdmin, "| the key is just a key:", [...m.keys()]);Frozen intrinsics: Hardened JavaScript
If the shared intrinsics cannot be mutated, a whole class of attacks disappears: pollution has nowhere to write, monkey-patching Array.prototype.map to exfiltrate data fails, and code can trust that Object.keys is the real one. Hardened JavaScript (formerly Secure ECMAScript, SES; now the ses package from Agoric and the basis of the TC39 Compartments and ShadowRealm work) does exactly this: at startup it lockdown()s the realm by transitively freezing every intrinsic, replacing a few that leak ambient authority (Date.now, Math.random, RegExp legacy statics, Error.prototype.stack accessors) with tamed versions, and enforcing the override mistake workaround. That mistake is a specification detail from chapter 4 worth restating: a frozen Object.prototype.toString makes obj.toString = f fail on every ordinary object, because OrdinarySet finds a non-writable inherited data property and refuses. SES converts such properties to accessors whose setter defines an own property, so freezing the intrinsics does not break code that assigns to inherited method names. Once locked down, each guest module runs in a Compartment with its own global object but the shared frozen intrinsics, so guests can be given exactly the capabilities they need (a fetch limited to one host, a clock, a logger) and nothing else.
// Work on a fresh class hierarchy so the sandbox's real intrinsics stay mutable for later probes.
class Base { describe() { return "base"; } }
Object.freeze(Base.prototype);
const inst = new Base();
inst.describe = () => "patched"; // sloppy mode: OrdinarySet fails silently
console.log("frozen: monkey-patch silently ignored in sloppy mode:", inst.describe() === "base", "| own keys:", Object.keys(inst));
(function () { "use strict"; try { inst.describe = () => "patched"; } catch (e) { console.log("override mistake:", e.constructor.name, "- assigning to an inherited non-writable method fails even though inst is extensible"); } })();
// The SES remedy: before freezing, replace each method's data property with an accessor whose
// setter defines an OWN property on the receiver. Then freeze. Assignment now works again.
class Hardened { describe() { return "base"; } }
for (const name of ["describe"]) {
const original = Hardened.prototype[name];
Object.defineProperty(Hardened.prototype, name, {
get() { return original; },
set(v) { Object.defineProperty(this, name, { value: v, writable: true, configurable: true, enumerable: true }); },
configurable: false, enumerable: false,
});
}
Object.freeze(Hardened.prototype);
const h = new Hardened();
h.describe = () => "patched per instance";
console.log("with the accessor remedy, assignment defines an own property:", h.describe(), "| prototype untouched:", new Hardened().describe());
// Doing it after freezing is too late: non-configurable properties cannot be redefined.
try { Object.defineProperty(Base.prototype, "describe", { get() {}, set() {} }); } catch (e) { console.log("after freezing it is too late:", e.constructor.name, "- lockdown() must run before any other code"); }The override mistake is why Object.freeze(Object.prototype) alone breaks real programs: obj.toString = f fails on every ordinary object. Hardened JavaScript installs the accessor remedy on every intrinsic method before freezing.
Membranes: controlled reachability with proxies
Freezing protects the intrinsics but says nothing about your objects. A membrane (chapter 10) is a boundary that wraps every reference crossing it in a proxy, in both directions, with a WeakMap ensuring each object maps to exactly one wrapper. Because the wrapper intercepts all thirteen internal methods, the membrane can enforce a policy at each crossing: revoke everything at once (Proxy.revocable), make the far side read-only, log or deny property access by name, or convert values (wrapping functions so their this and arguments are unwrapped and their results re-wrapped). The Proxy invariants guarantee that a wrapped object cannot lie about non-configurable properties in a way that would let a guest detect or defeat the membrane through inconsistent answers. Membranes are what Firefox uses internally between privileged and content code (xray wrappers), what SES Compartments use to share objects between compartments, and what the Realms API shim uses to keep two realms' intrinsics from leaking into each other.
function makeMembrane(root) {
const wrappers = new WeakMap();
const revokers = new Set();
const wrap = (value) => {
if ((typeof value !== "object" && typeof value !== "function") || value === null) return value;
if (wrappers.has(value)) return wrappers.get(value);
const { proxy, revoke } = Proxy.revocable(value, {
get: (t, k, r) => wrap(Reflect.get(t, k, t)), // receiver is the target, so getters see the real object
set: () => { throw new TypeError("membrane is read-only"); },
defineProperty: () => false,
deleteProperty: () => false,
apply: (t, thisArg, args) => wrap(Reflect.apply(t, thisArg, args)),
getPrototypeOf: (t) => wrap(Reflect.getPrototypeOf(t)),
});
revokers.add(revoke);
wrappers.set(value, proxy);
return proxy;
};
return { view: wrap(root), revoke: () => revokers.forEach((r) => r()) };
}
const secret = { config: { host: "db.internal", port: 5432 }, describe() { return this.config.host + ":" + this.config.port; } };
const { view, revoke } = makeMembrane(secret);
console.log("read through:", view.config.host, "| method calls unwrap this:", view.describe());
console.log("identity preserved across the membrane:", view.config === view.config, "| but not with the original:", view.config === secret.config);
try { view.config.port = 1; } catch (e) { console.log("write denied:", e.message); }
revoke();
try { view.config; } catch (e) { console.log("after revoke:", e.constructor.name, "- every wrapper dies at once, wherever it was stored"); }`eval`, `Function`, and the host's veto
The language's two string-to-code operations, direct/indirect eval and the Function constructor, cannot be removed for compatibility reasons, so the specification gives the host a veto: HostEnsureCanCompileStrings is called before any string is compiled and may throw. In browsers this is how Content Security Policy's absence of 'unsafe-eval' blocks eval, new Function, setTimeout(string), and the javascript: URL scheme, and how Trusted Types (require-trusted-types-for 'script') can demand that the string be a TrustedScript object rather than a plain string. The hook is defined at the specification level precisely so that hosts can deny dynamic code without every engine inventing its own mechanism. Note what it does not cover: import() of a data: or blob: URL is module loading, governed by script-src rather than 'unsafe-eval', and a Worker from a Blob is a new agent whose policy is inherited from the creator's document. The probes in this text run inside exactly such a Worker; a page with a strict CSP would need worker-src blob: and script-src 'unsafe-eval' for them to work, and the atlas edition, which ships no scripts at all, is the tightest policy possible.
Spectre: when the timer is the vulnerability
Spectre (2018) showed that a JavaScript program can read memory it has no reference to. The mechanism is not a language bug: a branch such as if (i < arr.length) x = arr[i] is speculatively executed by the CPU for an out-of-bounds i before the bounds check resolves, the speculative load brings a cache line whose address depends on the secret value into the cache, and the program then measures which line is cached by timing subsequent accesses. Two ingredients are needed: a way to induce speculation on secret-dependent addresses (any JIT-compiled array access qualifies), and a timer precise enough to distinguish a cache hit (a few nanoseconds) from a miss. Browsers could not remove the first, so they attacked the second and the blast radius: performance.now() was coarsened to 5–100 µs with jitter; SharedArrayBuffer was disabled entirely, because a Worker incrementing a shared counter is a nanosecond-resolution clock that no coarsening can remove; and Site Isolation put each site in its own OS process so that a successful read cannot reach another origin's memory. SharedArrayBuffer returned only under cross-origin isolation (COOP + COEP, chapter 11), where the process is guaranteed to contain only resources that consented to be there, so there is nothing cross-origin left to read.
// How fine is performance.now()? Sample the smallest positive difference between consecutive reads.
let minDelta = Infinity, last = performance.now();
for (let i = 0; i < 200000; i++) { const t = performance.now(); const d = t - last; if (d > 0 && d < minDelta) minDelta = d; last = t; }
console.log("crossOriginIsolated:", globalThis.crossOriginIsolated, "| finest observable performance.now() step: ~" + (minDelta * 1000).toFixed(1) + " µs");
console.log("Browsers coarsen this to 5 µs (isolated) or 100 µs (not isolated) with jitter; Spectre needs ~10 ns to see a cache miss.");
if (typeof SharedArrayBuffer === "function") {
// A Worker spinning on a shared counter is a clock with no coarsening: this is why SAB needs isolation.
const sab = new SharedArrayBuffer(8), ctr = new Int32Array(sab);
const w = new Worker(URL.createObjectURL(new Blob(["onmessage = ({data}) => { const c = new Int32Array(data); for (;;) Atomics.add(c, 0, 1); }"], { type: "text/javascript" })));
w.postMessage(sab);
await new Promise((r) => setTimeout(r, 50));
const a = Atomics.load(ctr, 0); const t0 = performance.now();
await new Promise((r) => setTimeout(r, 50));
const ticks = Atomics.load(ctr, 0) - a, ms = performance.now() - t0;
w.terminate();
console.log("shared-memory clock: ~" + (ticks / ms / 1000).toFixed(1) + " ticks per µs, i.e. ~" + (1000 / (ticks / ms)).toFixed(1) + " ns resolution");
} else {
console.log("SharedArrayBuffer is unavailable here, which is the mitigation in action.");
}This probe only measures clocks; it performs no cache-timing attack. The point is that cross-origin isolation is what makes handing the page a nanosecond clock acceptable.
Supply chain: the realm is the trust boundary, and `import` crosses it
Every dependency imported into an application runs in the same realm with the same reachability as first-party code: it can patch intrinsics, read process.env, open sockets, and register FinalizationRegistry callbacks that run later. Node's module system adds two more execution points a lockfile does not make safe: install scripts (preinstall/postinstall) run arbitrary code on the developer's machine at npm install, and bundlers' plugin systems run dependency code at build time. The 2018 event-stream, 2021 ua-parser-js, and 2024 polyfill.io incidents each used one of these paths. The language-level responses are the mechanisms in this chapter (frozen intrinsics so a dependency cannot patch what others rely on; Compartments so each package receives only the globals it is granted) plus host-level ones: Node's permission model (--permission, --allow-fs-read), npm install --ignore-scripts, Deno's default-deny permissions, import maps and Subresource Integrity in browsers, and lockfile-pinned, provenance-attested registries. None of them changes the fact that a Proxy around an untrusted object, or a WeakMap holding private state, is only as strong as the intrinsics it relies on; which is why Hardened JavaScript freezes those first.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Object-capability security | Mark S. Miller, Robust Composition: Towards a Unified Approach to Access Control and Concurrency Control (PhD thesis) (2006) | Miller joined TC39 and drove `Object.freeze`, Proxies, WeakMaps, Realms, and the SES/Hardened JavaScript effort: the tools needed to make JavaScript an object-capability language. |
| Membranes | Tom Van Cutsem & Mark S. Miller, Trustworthy Proxies: Virtualizing Objects with Invariants (2013) | Defined the Proxy invariants (chapter 10) so that membranes built from proxies cannot be broken by the objects they wrap. |
| Prototype pollution | Olivier Arteau, Prototype Pollution Attack in NodeJS Application (NorthSec) (2018) | Named and catalogued the attack against merge/extend utilities; lodash, jQuery, and hundreds of packages patched the pattern afterwards. |
| Speculative-execution side channels | Paul Kocher, Jann Horn, et al., Spectre Attacks: Exploiting Speculative Execution (2018) | Demonstrated in JavaScript from a web page; led to `performance.now()` coarsening, the removal and re-gating of SharedArrayBuffer behind cross-origin isolation, and Site Isolation in browsers. |
| Same-origin policy | Netscape Communications, Netscape Navigator 2.0 security model (1995) | The host-level boundary that realms map onto: a cross-origin frame is a realm you can hold a reference to but not read. |
Primary sources
- ECMA-262, §19.2.1.2 HostEnsureCanCompileStrings
- ECMA-262, §B.2.2.1 Object.prototype.__proto__
- Agoric: Hardened JavaScript (SES) and lockdown()
- TC39 proposal: Compartments
- Van Cutsem, T. & Miller, M. S. (2013). Trustworthy Proxies: Virtualizing Objects with Invariants
- Arteau, O. (2018). Prototype Pollution Attack in NodeJS Application
- Kocher, P. et al. (2018). Spectre Attacks: Exploiting Speculative Execution
- Chromium: Site Isolation design document
- W3C: Content Security Policy Level 3, 'unsafe-eval' and Trusted Types integration
- Node.js: Permission model