@johnhenry/andbox 0.0.8 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/capability-gate.mjs +19 -3
- package/src/sandbox.mjs +9 -5
- package/src/wasm-worker-source.mjs +1 -1
- package/src/worker-source.mjs +2 -1
package/README.md
CHANGED
|
@@ -427,7 +427,7 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
427
427
|
- **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
|
|
428
428
|
- **(Node) A worker thread is not a security boundary.** It shares the process with the host; by default it inherits `process.env` and can reach `process`, `fs`, and `child_process`. The opt-in `nodeWorker.permissions` hardening (see [Node](#node)) adds the permission model, an isolated env, a memory cap and import blocking, which raises the cost of casual abuse but does not make the thread a boundary.
|
|
429
429
|
- **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
|
|
430
|
-
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor.
|
|
430
|
+
- **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor; the host resolves names through a `Map` and rejects anything that was not explicitly granted. First fixed in 0.0.1, hardened in 0.0.9; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
|
|
431
431
|
- **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
|
|
432
432
|
- **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
|
|
433
433
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode",
|
|
6
6
|
"main": "./src/index.mjs",
|
package/src/capability-gate.mjs
CHANGED
|
@@ -21,11 +21,12 @@ import { DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
|
|
|
21
21
|
*
|
|
22
22
|
* @param {Record<string, Function>} capabilities - Raw capability functions.
|
|
23
23
|
* @param {GatePolicy} [policy] - Rate limit policy.
|
|
24
|
-
* @returns {{ gated: Record<string, Function>, stats: () => object }}
|
|
24
|
+
* @returns {{ gated: Record<string, Function>, lookup: (name: unknown) => Function | undefined, stats: () => object }}
|
|
25
25
|
*/
|
|
26
26
|
export function gateCapabilities(capabilities, policy = {}) {
|
|
27
27
|
const limits = { ...DEFAULT_LIMITS, ...policy.limits };
|
|
28
28
|
const capPolicies = policy.capabilities || {};
|
|
29
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
29
30
|
|
|
30
31
|
let totalCalls = 0;
|
|
31
32
|
let totalArgBytes = 0;
|
|
@@ -41,9 +42,12 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
41
42
|
// like 'constructor' or 'toString' cannot resolve through the prototype
|
|
42
43
|
// to real global functions and bypass the allowlist/rate limiting below.
|
|
43
44
|
const gated = Object.create(null);
|
|
45
|
+
// Authoritative table for lookup(): a Map has no prototype-chain property
|
|
46
|
+
// semantics at all, so a string key either was granted or is absent.
|
|
47
|
+
const table = new Map();
|
|
44
48
|
|
|
45
49
|
for (const [name, fn] of Object.entries(capabilities)) {
|
|
46
|
-
const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...capPolicies[name] };
|
|
50
|
+
const capLimits = { ...DEFAULT_CAPABILITY_LIMITS, ...(hasOwn(capPolicies, name) ? capPolicies[name] : undefined) };
|
|
47
51
|
|
|
48
52
|
gated[name] = async (...args) => {
|
|
49
53
|
// Measure argument bytes
|
|
@@ -83,6 +87,18 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
83
87
|
concurrent--;
|
|
84
88
|
}
|
|
85
89
|
};
|
|
90
|
+
table.set(name, gated[name]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a capability by name. Only names the host explicitly granted
|
|
95
|
+
* resolve; non-strings and anything that exists only on Object.prototype
|
|
96
|
+
* (constructor, toString, __proto__, ...) return undefined.
|
|
97
|
+
* @param {unknown} name
|
|
98
|
+
* @returns {Function | undefined}
|
|
99
|
+
*/
|
|
100
|
+
function lookup(name) {
|
|
101
|
+
return typeof name === 'string' ? table.get(name) : undefined;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
function stats() {
|
|
@@ -94,5 +110,5 @@ export function gateCapabilities(capabilities, policy = {}) {
|
|
|
94
110
|
};
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
return { gated, stats };
|
|
113
|
+
return { gated, lookup, stats };
|
|
98
114
|
}
|
package/src/sandbox.mjs
CHANGED
|
@@ -436,7 +436,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
// Gate capabilities with rate limits
|
|
439
|
-
const {
|
|
439
|
+
const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(capabilities, policy);
|
|
440
440
|
|
|
441
441
|
// Console handler — mutable so evaluate() can swap per-call
|
|
442
442
|
let activeConsoleHandler = onConsole || null;
|
|
@@ -490,7 +490,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
490
490
|
|
|
491
491
|
case 'result': {
|
|
492
492
|
const entry = pending.get(msg.id);
|
|
493
|
-
if (entry) {
|
|
493
|
+
if (entry && entry.nonce === msg.nonce) {
|
|
494
494
|
pending.delete(msg.id);
|
|
495
495
|
if (entry.timer) clearTimeout(entry.timer);
|
|
496
496
|
if (msg.stats) {
|
|
@@ -570,7 +570,7 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
570
570
|
}
|
|
571
571
|
|
|
572
572
|
async function handleCapabilityCall(rpcId, name, args) {
|
|
573
|
-
const fn =
|
|
573
|
+
const fn = lookupCapability(name);
|
|
574
574
|
if (!fn) {
|
|
575
575
|
worker.postMessage({
|
|
576
576
|
type: 'capabilityResult',
|
|
@@ -637,6 +637,10 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
637
637
|
// can't guess the id of a concurrent evaluate() on the same worker and
|
|
638
638
|
// forge a matching message to interfere with it.
|
|
639
639
|
const id = crypto.randomUUID();
|
|
640
|
+
// Per-call nonce: the worker echoes it in its `result`; a result whose
|
|
641
|
+
// nonce does not match is dropped. Held only by the host and the worker's
|
|
642
|
+
// message handler, never exposed to evaluated code.
|
|
643
|
+
const nonce = crypto.randomUUID() + crypto.randomUUID();
|
|
640
644
|
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
641
645
|
const { promise, resolve, reject } = makeDeferred();
|
|
642
646
|
|
|
@@ -691,8 +695,8 @@ async function createWorkerSandbox(options = {}, forceNode = false, isWasm = fal
|
|
|
691
695
|
}, { once: true });
|
|
692
696
|
}
|
|
693
697
|
|
|
694
|
-
pending.set(id, { resolve, reject, timer });
|
|
695
|
-
worker.postMessage({ type: 'evaluate', id, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
|
|
698
|
+
pending.set(id, { resolve, reject, timer, nonce });
|
|
699
|
+
worker.postMessage({ type: 'evaluate', id, nonce, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
|
|
696
700
|
|
|
697
701
|
// Restore console handler when evaluation completes
|
|
698
702
|
return promise.finally(() => {
|
|
@@ -462,7 +462,7 @@ function wasmWorkerMain() {
|
|
|
462
462
|
}
|
|
463
463
|
case 'evaluate': {
|
|
464
464
|
const res = await runEval(msg);
|
|
465
|
-
self.postMessage({ type: 'result', id: msg.id, ...res });
|
|
465
|
+
self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, ...res });
|
|
466
466
|
break;
|
|
467
467
|
}
|
|
468
468
|
case 'capabilityResult': {
|
package/src/worker-source.mjs
CHANGED
|
@@ -175,11 +175,12 @@ self.onmessage = async ({ data: msg }) => {
|
|
|
175
175
|
\`return (async () => {\\n\${msg.code}\\n})();\`
|
|
176
176
|
);
|
|
177
177
|
const result = await asyncFn(sandboxImport, host, fwdConsole);
|
|
178
|
-
self.postMessage({ type: 'result', id: msg.id, success: true, value: serialize(result) });
|
|
178
|
+
self.postMessage({ type: 'result', id: msg.id, nonce: msg.nonce, success: true, value: serialize(result) });
|
|
179
179
|
} catch (e) {
|
|
180
180
|
self.postMessage({
|
|
181
181
|
type: 'result',
|
|
182
182
|
id: msg.id,
|
|
183
|
+
nonce: msg.nonce,
|
|
183
184
|
success: false,
|
|
184
185
|
error: { message: e.message || String(e), name: e.name || 'Error', stack: e.stack },
|
|
185
186
|
});
|