Skip to content

Commit 4a6a31f

Browse files
feat(affine-js): capability-restricted sandbox loader for untrusted guests (#681)
## What — the mod-wedge's third safety axis Today `buildImportObject` hands a guest whatever the host passes: the capability set is implicit and all-or-nothing. `sandbox.js` makes it **explicit and fail-closed at load time**: ```js const caps = defineCapabilities({ "dom.read": { env: { dom_query_selector, dom_str_eq } }, "score": { env: { game_add_point } }, "io.stdout": { wasi_snapshot_preview1: { fd_write } }, }); const { importObject, audit } = buildSandboxedImports(module, { capabilities: caps, grant: ["dom.read", "score"], // ← the mod's rights }); ``` - **Fail-closed at load**: the guest's *own wasm import section* is checked against the grant **before instantiation**. A guest declaring any import outside its grant throws `CapabilityError` (all violations listed) — a hostile code path can never sit dormant behind an ungranted import waiting for a call. - **Least privilege**: the import object carries *only* imports the module actually requests; every satisfied import is audited `{module, name, capability}`. - **Non-function imports refused by default**: imported memory is the L13 isolation red flag (safe guests export their own); tables/globals are host-state channels. `allowMemoryImport` waives memory only. - **WASI uncased**: `fd_write` is just a namespaced function — "may print" is an ordinary grantable capability. Composes with the existing axes: wasm memory isolation (opaque `Int` handles) + the `typedwasm.ownership` verifier (`parseOwnershipSection`, L10/L13) pre-load. ## Tests — 12 new, hand-rolled wasm fixtures with real import sections Fail-closed (incl. all-violations-at-once, defined-but-ungranted), memory/table/global refusal + waiver, least-privilege + audit, registry hygiene, and a **live call-through**: a guest wasm calling a granted host fn twice — and the *identical* guest never instantiating without the grant. `deno task test` now runs both suites: **26 passed, 0 failed**. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f992650 commit 4a6a31f

3 files changed

Lines changed: 258 additions & 3 deletions

File tree

packages/affine-js/deno.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
".": "./mod.js",
66
"./loader": "./loader.js",
77
"./marshal": "./marshal.js",
8-
"./runtime": "./runtime.js"
8+
"./runtime": "./runtime.js",
9+
"./sandbox": "./sandbox.js"
910
},
1011
"license": "MPL-2.0",
1112
"tasks": {
12-
"test": "deno test --allow-read --allow-write loader_test.js"
13+
"test": "deno test --allow-read --allow-write loader_test.js sandbox_test.js"
1314
},
1415
"publish": {
15-
"exclude": ["loader_test.js"]
16+
"exclude": [
17+
"loader_test.js",
18+
"sandbox_test.js"
19+
]
1620
}
1721
}

packages/affine-js/sandbox.js

6.49 KB
Binary file not shown.

packages/affine-js/sandbox_test.js

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// affine-js/sandbox: capability-restricted import-object tests.
5+
//
6+
// Run: deno test packages/affine-js/sandbox_test.js
7+
//
8+
// Fixtures are hand-rolled wasm binaries (same approach as loader_test.js's
9+
// wasmWithCustomSection): a type section with one () -> () type, an import
10+
// section declaring exactly the imports under test, and — for the live
11+
// call-through test — a function that calls import 0, exported as "poke".
12+
13+
import { assertEquals, assertThrows } from "jsr:@std/assert@1";
14+
import {
15+
buildSandboxedImports,
16+
CapabilityError,
17+
defineCapabilities,
18+
} from "./sandbox.js";
19+
20+
// ── wasm fixture builders ─────────────────────────────────────────────────────
21+
22+
function uleb(n) {
23+
const out = [];
24+
do {
25+
let b = n & 0x7f;
26+
n >>>= 7;
27+
if (n !== 0) b |= 0x80;
28+
out.push(b);
29+
} while (n !== 0);
30+
return out;
31+
}
32+
33+
function section(id, content) {
34+
return [id, ...uleb(content.length), ...content];
35+
}
36+
37+
function name(s) {
38+
const b = [...new TextEncoder().encode(s)];
39+
return [...uleb(b.length), ...b];
40+
}
41+
42+
/** One import entry. kind: "function" | "memory" | "table" | "global". */
43+
function importEntry(mod, nm, kind) {
44+
const desc = {
45+
function: [0x00, ...uleb(0)], // type index 0: () -> ()
46+
table: [0x01, 0x70, 0x00, ...uleb(0)], // funcref, min 0
47+
memory: [0x02, 0x00, ...uleb(1)], // min 1 page
48+
global: [0x03, 0x7f, 0x00], // i32 immutable
49+
}[kind];
50+
return [...name(mod), ...name(nm), ...desc];
51+
}
52+
53+
/** Module that just declares imports (never needs to instantiate). */
54+
function moduleWithImports(entries) {
55+
const typeSec = section(1, [0x01, 0x60, 0x00, 0x00]); // 1 type: () -> ()
56+
const importSec = section(2, [
57+
...uleb(entries.length),
58+
...entries.flatMap(([m, n, k]) => importEntry(m, n, k)),
59+
]);
60+
return new WebAssembly.Module(new Uint8Array([
61+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
62+
...typeSec, ...importSec,
63+
]));
64+
}
65+
66+
/** Module importing fn `mod.nm` and exporting "poke" () that calls it. */
67+
function moduleCallingImport(mod, nm) {
68+
const typeSec = section(1, [0x01, 0x60, 0x00, 0x00]);
69+
const importSec = section(2, [0x01, ...importEntry(mod, nm, "function")]);
70+
const funcSec = section(3, [0x01, 0x00]); // 1 func of type 0
71+
const exportSec = section(7, [0x01, ...name("poke"), 0x00, ...uleb(1)]);
72+
const body = [0x00, 0x10, 0x00, 0x0b]; // no locals; call 0; end
73+
const codeSec = section(10, [0x01, ...uleb(body.length), ...body]);
74+
return new WebAssembly.Module(new Uint8Array([
75+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
76+
...typeSec, ...importSec, ...funcSec, ...exportSec, ...codeSec,
77+
]));
78+
}
79+
80+
// ── shared registry ───────────────────────────────────────────────────────────
81+
82+
const noop = () => {};
83+
const CAPS = defineCapabilities({
84+
"dom.read": { env: { dom_query_selector: noop, dom_str_eq: noop } },
85+
"dom.write": { env: { dom_create_element: noop, dom_append_child: noop } },
86+
"io.stdout": { wasi_snapshot_preview1: { fd_write: noop } },
87+
});
88+
89+
// ── defineCapabilities validation ─────────────────────────────────────────────
90+
91+
Deno.test("defineCapabilities rejects a non-function member", () => {
92+
assertThrows(
93+
() => defineCapabilities({ bad: { env: { oops: 42 } } }),
94+
TypeError,
95+
"not a function",
96+
);
97+
});
98+
99+
// ── fail-closed at load ───────────────────────────────────────────────────────
100+
101+
Deno.test("ungranted function import rejects the whole guest at load", () => {
102+
const mod = moduleWithImports([
103+
["env", "dom_query_selector", "function"],
104+
["env", "dom_delete_everything", "function"], // never defined anywhere
105+
]);
106+
const err = assertThrows(
107+
() =>
108+
buildSandboxedImports(mod, { capabilities: CAPS, grant: ["dom.read"] }),
109+
CapabilityError,
110+
"dom_delete_everything",
111+
);
112+
assertEquals(err.violations.length, 1);
113+
assertEquals(err.violations[0].name, "dom_delete_everything");
114+
});
115+
116+
Deno.test("defined-but-ungranted capability still rejects (grant is the gate)", () => {
117+
const mod = moduleWithImports([["env", "dom_create_element", "function"]]);
118+
assertThrows(
119+
() =>
120+
buildSandboxedImports(mod, { capabilities: CAPS, grant: ["dom.read"] }),
121+
CapabilityError,
122+
"dom_create_element",
123+
);
124+
});
125+
126+
Deno.test("all violations reported at once, not first-only", () => {
127+
const mod = moduleWithImports([
128+
["env", "a", "function"],
129+
["env", "b", "function"],
130+
]);
131+
const err = assertThrows(
132+
() => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }),
133+
CapabilityError,
134+
);
135+
assertEquals(err.violations.map((v) => v.name), ["a", "b"]);
136+
});
137+
138+
// ── non-function imports ──────────────────────────────────────────────────────
139+
140+
Deno.test("imported memory is refused by default (L13 red flag)", () => {
141+
const mod = moduleWithImports([["env", "memory", "memory"]]);
142+
const err = assertThrows(
143+
() => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }),
144+
CapabilityError,
145+
);
146+
assertEquals(err.violations[0].kind, "memory");
147+
});
148+
149+
Deno.test("allowMemoryImport waives only the memory refusal", () => {
150+
const mod = moduleWithImports([["env", "memory", "memory"]]);
151+
const { requested } = buildSandboxedImports(mod, {
152+
capabilities: CAPS,
153+
grant: [],
154+
allowMemoryImport: true,
155+
});
156+
assertEquals(requested.length, 1);
157+
});
158+
159+
Deno.test("imported table/global are refused even with allowMemoryImport", () => {
160+
const mod = moduleWithImports([
161+
["env", "t", "table"],
162+
["env", "g", "global"],
163+
]);
164+
const err = assertThrows(
165+
() =>
166+
buildSandboxedImports(mod, {
167+
capabilities: CAPS,
168+
grant: [],
169+
allowMemoryImport: true,
170+
}),
171+
CapabilityError,
172+
);
173+
assertEquals(err.violations.map((v) => v.kind), ["table", "global"]);
174+
});
175+
176+
// ── least privilege + audit ───────────────────────────────────────────────────
177+
178+
Deno.test("import object carries only requested imports; audit names the capability", () => {
179+
const mod = moduleWithImports([
180+
["env", "dom_query_selector", "function"],
181+
["wasi_snapshot_preview1", "fd_write", "function"],
182+
]);
183+
const { importObject, audit, granted } = buildSandboxedImports(mod, {
184+
capabilities: CAPS,
185+
grant: ["dom.read", "dom.write", "io.stdout"],
186+
});
187+
// dom.read also defines dom_str_eq and dom.write is fully unrequested:
188+
// neither may leak into the instance surface.
189+
assertEquals(Object.keys(importObject.env), ["dom_query_selector"]);
190+
assertEquals(Object.keys(importObject.wasi_snapshot_preview1), ["fd_write"]);
191+
assertEquals(granted, ["dom.read", "dom.write", "io.stdout"]);
192+
assertEquals(
193+
audit.map((a) => `${a.module}.${a.name}<-${a.capability}`),
194+
[
195+
"env.dom_query_selector<-dom.read",
196+
"wasi_snapshot_preview1.fd_write<-io.stdout",
197+
],
198+
);
199+
});
200+
201+
// ── registry hygiene ──────────────────────────────────────────────────────────
202+
203+
Deno.test("unknown grant name is a TypeError (authoring error, not guest's fault)", () => {
204+
const mod = moduleWithImports([]);
205+
assertThrows(
206+
() => buildSandboxedImports(mod, { capabilities: CAPS, grant: ["nope"] }),
207+
TypeError,
208+
'unknown capability "nope"',
209+
);
210+
});
211+
212+
Deno.test("two granted capabilities defining the same import differently is loud", () => {
213+
const mod = moduleWithImports([]);
214+
const clashing = defineCapabilities({
215+
a: { env: { f: () => 1 } },
216+
b: { env: { f: () => 2 } },
217+
});
218+
assertThrows(
219+
() =>
220+
buildSandboxedImports(mod, { capabilities: clashing, grant: ["a", "b"] }),
221+
TypeError,
222+
"different implementations",
223+
);
224+
});
225+
226+
// ── live call-through ─────────────────────────────────────────────────────────
227+
228+
Deno.test("granted guest instantiates and calls through to the host fn", () => {
229+
let hits = 0;
230+
const caps = defineCapabilities({
231+
score: { env: { game_add_point: () => hits++ } },
232+
});
233+
const mod = moduleCallingImport("env", "game_add_point");
234+
const { importObject } = buildSandboxedImports(mod, {
235+
capabilities: caps,
236+
grant: ["score"],
237+
});
238+
const inst = new WebAssembly.Instance(mod, importObject);
239+
inst.exports.poke();
240+
inst.exports.poke();
241+
assertEquals(hits, 2);
242+
});
243+
244+
Deno.test("the same guest without the grant never instantiates", () => {
245+
const mod = moduleCallingImport("env", "game_add_point");
246+
assertThrows(
247+
() => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }),
248+
CapabilityError,
249+
"game_add_point",
250+
);
251+
});

0 commit comments

Comments
 (0)