Skip to content

Commit 8dd0591

Browse files
authored
perf: shallow-scan in containsDangerousConstructor (#564) (#565)
The recursive own-property scan walked the entire reachable graph on every bridge crossing. For host libraries returning objects with deep cross-references (e.g. DOM wrappers with ownerDocument back-pointers), this caused ~175x slowdown per the reproduction in #564. Replace the recursive scan with a shallow scan of direct own-property descriptors. Nested host objects are independently scanned when they themselves cross the bridge, so the existing layered descriptor-extraction attacks (covered by the depth-2 and depth-3 nested attack tests) remain blocked at the layer where the Function constructor is exposed at depth 1. Adds a regression test that builds a 1000-node cross-referenced graph and asserts the bridge crossing completes under 2s; the unfixed code takes ~8s on the same machine. Reported by @bglick. Fixes #564.
1 parent cdeb688 commit 8dd0591

3 files changed

Lines changed: 65 additions & 15 deletions

File tree

docs/ATTACKS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,7 @@ When sandbox proxies are passed to host functions, they are unwrapped via `mappi
10551055
10561056
### Mitigation
10571057
1058-
Objects containing dangerous constructors are proxied with `preventUnwrap` -- they are NOT registered in `mappingThisToOther`. When passed to host functions, they cannot be unwrapped; instead the bridge creates a double-proxy where all property access goes through sanitizing traps. The proxy's `get` trap returns `{}` for dangerous constructor values. Dangerous constructors are detected recursively at any nesting depth via `containsDangerousConstructor` with cycle detection (WeakMap).
1058+
Objects containing dangerous constructors are proxied with `preventUnwrap` -- they are NOT registered in `mappingThisToOther`. When passed to host functions, they cannot be unwrapped; instead the bridge creates a double-proxy where all property access goes through sanitizing traps. The proxy's `get` trap returns `{}` for dangerous constructor values. `containsDangerousConstructor` performs a shallow scan of own property descriptors at each bridge crossing; nested host objects are scanned independently when they themselves cross the bridge, so layered descriptor-extraction attacks (`getOwnPropertyDescriptor` on `getOwnPropertyDescriptors` results, etc.) are caught at the layer where the Function constructor is exposed at depth 1.
10591059
10601060
### Detection Rules
10611061

lib/bridge.js

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -544,17 +544,30 @@ function createBridge(otherInit, registerProxy) {
544544
(otherAsyncGeneratorFunctionCtor && value === otherAsyncGeneratorFunctionCtor); // AsyncGeneratorFunction is not available on Node < 10
545545
}
546546

547-
// Check if an object contains a dangerous function constructor in ANY of its
548-
// own property values (data or accessor), recursively at any nesting depth.
549-
// Uses cycle detection and otherSafeGetOwnPropertyDescriptor (not otherReflectGet)
550-
// to avoid triggering getters.
551-
function containsDangerousConstructor(obj, visited) {
547+
// Check if an object's own property descriptors contain a dangerous function
548+
// constructor (data value or accessor get/set). This is a shallow check —
549+
// it does NOT recurse into nested object values.
550+
//
551+
// Why shallow is sufficient:
552+
// Each value crossing the bridge is wrapped in its own proxy, which triggers
553+
// its own call to this function. A nested host object reachable as obj.x.y
554+
// only becomes a separate bridge crossing when the sandbox accesses obj.x —
555+
// at which point the inner object is shallow-checked. If the inner object's
556+
// own descriptors hold a Function constructor, it gets flagged dangerous on
557+
// its own crossing and cannot be unwrapped back to the host. The existing
558+
// tests `getOwnPropertyDescriptor on getOwnPropertyDescriptors result` and
559+
// `triple-nested getOwnPropertyDescriptors attack` verify this preserves
560+
// protection against depth-2 and depth-3 chained-descriptor attacks.
561+
//
562+
// Why this avoids the perf regression in #564:
563+
// The previous recursive implementation walked all reachable own properties
564+
// on every bridge crossing. For host libraries returning objects with deep
565+
// cross-references (e.g. DOM wrappers where every node points back to
566+
// ownerDocument), a single crossing triggered a walk of the entire object
567+
// graph — O(graph) per crossing instead of O(direct properties).
568+
function containsDangerousConstructor(obj) {
552569
if (obj === null || typeof obj !== 'object') return false;
553570

554-
if (!visited) visited = new ThisWeakMap();
555-
if (thisReflectApply(thisWeakMapGet, visited, [obj])) return false;
556-
thisReflectApply(thisWeakMapSet, visited, [obj, true]);
557-
558571
let keys;
559572
try {
560573
keys = otherReflectOwnKeys(obj);
@@ -573,11 +586,8 @@ function createBridge(otherInit, registerProxy) {
573586

574587
if (desc.get || desc.set) {
575588
if (isDangerousFunctionConstructor(desc.get) || isDangerousFunctionConstructor(desc.set)) return true;
576-
} else {
577-
if (isDangerousFunctionConstructor(desc.value)) return true;
578-
if (desc.value !== null && typeof desc.value === 'object') {
579-
if (containsDangerousConstructor(desc.value, visited)) return true;
580-
}
589+
} else if (isDangerousFunctionConstructor(desc.value)) {
590+
return true;
581591
}
582592
}
583593
return false;

test/vm.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,46 @@ describe('contextify', () => {
408408
assert.doesNotThrow(() => vm.run('(o) => o.arguments')({arguments: 1}));
409409
});
410410

411+
it('cross-referenced object graph (issue #564)', () => {
412+
// Regression test for #564: when a host library returns objects whose
413+
// own properties cross-reference each other (e.g. DOM nodes pointing
414+
// back to ownerDocument), bridge crossings must not walk the entire
415+
// reachable object graph. The recursive scan in containsDangerousConstructor
416+
// caused ~175x slowdown for these workloads. This test builds a graph of
417+
// 1000 nodes where every node references every other node, and verifies
418+
// it crosses the bridge in a reasonable time. Reference timings:
419+
// shallow scan (fixed): ~10ms, recursive scan (regression): ~8000ms.
420+
const NODE_COUNT = 1000;
421+
const nodes = [];
422+
for (let i = 0; i < NODE_COUNT; i++) {
423+
nodes.push({id: i, label: 'node-' + i, payload: {kind: 'data', index: i}});
424+
}
425+
// Cross-reference: every node sees every other node (worst case for
426+
// recursive scanning, easy case for shallow scanning).
427+
for (let i = 0; i < NODE_COUNT; i++) {
428+
nodes[i].siblings = nodes;
429+
nodes[i].self = nodes[i];
430+
nodes[i].first = nodes[0];
431+
nodes[i].last = nodes[NODE_COUNT - 1];
432+
}
433+
const sandbox = {graph: nodes};
434+
const localVm = new VM({sandbox});
435+
const start = Date.now();
436+
// Touch every node so each one crosses the bridge at least once.
437+
const visited = localVm.run(`
438+
let count = 0;
439+
for (const n of graph) {
440+
if (n.label && n.payload.kind === 'data') count++;
441+
}
442+
count;
443+
`);
444+
const elapsed = Date.now() - start;
445+
assert.strictEqual(visited, NODE_COUNT);
446+
// Generous bound — on a developer machine this completes in <100ms;
447+
// the unfixed bug would push it to several seconds for this graph.
448+
assert.ok(elapsed < 2000, `bridge crossing too slow: ${elapsed}ms (regression of #564)`);
449+
});
450+
411451
after(() => {
412452
vm = null;
413453
});

0 commit comments

Comments
 (0)