From b5a11bb0613c43fcd26d2fe09ba731f93a539963 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:22:47 +0100 Subject: [PATCH] feat(affine-js): capability-restricted sandbox loader for untrusted guests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mod-wedge 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: - defineCapabilities({name: {ns: {fn}}}) — named, validated, frozen bundles of host functions ("dom.read", "io.stdout", ...). - buildSandboxedImports(module, {capabilities, grant, allowMemoryImport}) reads the guest's OWN wasm import section (WebAssembly.Module.imports) and rejects the whole guest with a CapabilityError — before instantiation — if any declared import is outside the grant. A hostile code path can never sit dormant behind an ungranted import. - Least privilege: the returned import object carries ONLY imports the module actually requests (granting a bundle does not spray its whole surface into the instance). Every satisfied import is audited {module, name, capability}. - Non-function imports are refused by default: imported memory is the L13 module-isolation red flag (a safe guest exports its own); tables/globals are host-state channels. allowMemoryImport waives only the memory refusal, for embedders that deliberately share memory. - WASI needs no special casing: fd_write is just a namespaced function, so "may print" is an ordinary grantable capability. Composes with the existing two axes: wasm memory isolation (opaque Int handles, no host pointers) and the typedwasm.ownership verifier (L10 linearity / L13 isolation, parseOwnershipSection pre-load). Tests (12, hand-rolled wasm fixtures with real import sections, same style as loader_test.js): fail-closed rejection incl. all-violations- at-once, defined-but-ungranted still rejects, memory/table/global refusal + the memory waiver, least-privilege + audit, registry hygiene (unknown grant, conflicting duplicate implementations), 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. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/affine-js/deno.json | 10 +- packages/affine-js/sandbox.js | Bin 0 -> 6650 bytes packages/affine-js/sandbox_test.js | 251 +++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 packages/affine-js/sandbox.js create mode 100644 packages/affine-js/sandbox_test.js diff --git a/packages/affine-js/deno.json b/packages/affine-js/deno.json index 98c88318..f1e82fc6 100644 --- a/packages/affine-js/deno.json +++ b/packages/affine-js/deno.json @@ -5,13 +5,17 @@ ".": "./mod.js", "./loader": "./loader.js", "./marshal": "./marshal.js", - "./runtime": "./runtime.js" + "./runtime": "./runtime.js", + "./sandbox": "./sandbox.js" }, "license": "MPL-2.0", "tasks": { - "test": "deno test --allow-read --allow-write loader_test.js" + "test": "deno test --allow-read --allow-write loader_test.js sandbox_test.js" }, "publish": { - "exclude": ["loader_test.js"] + "exclude": [ + "loader_test.js", + "sandbox_test.js" + ] } } diff --git a/packages/affine-js/sandbox.js b/packages/affine-js/sandbox.js new file mode 100644 index 0000000000000000000000000000000000000000..d6841ad204c20f9ff4da4932a39a2462a9e170b5 GIT binary patch literal 6650 zcmcgwVQQSuSZsrxLgy zali0>$-UXxC27ZPkS`}7jzn^2XWqPdGt2#bI{*32KPD&XLK~|m$FVk5nxwj%(&^79 zlV{PBz5RW9ofq3OU9Kt`E=Kh1$+N%GPq|UmN*Q_+9Y*w%-smi&;c8pxvdFXT8aGGu z;yQ{W6j(EU6mxM(UWVtZJMP(_;OR7y())qI}(6Yv}mWJy*wzN-0RxeXyM-hEkX~MUPb1nWyQT~dsdBQ`>%81jYV%B0X>Q1U{`>F$$W~*7oo4Z|OU0SX#Egbnu3}wA*LFl;1JLibWOp1)d`U1;)&ep3%D(XBF5LjLLgbw4 zEM277*u=c~Brhi>N9N3u(}^dL&pJa|)eUT=6`Rr@dyC#49-mBJpPZc^y`jSobaHn1 zhCUph9!=ej&XHIzG6jB-QESkv^MZ}dYZGVMj%isc16C=r^_;x}w*}3ybFPi_FP4yE z;g2wt%~(*CnFEg(E4{eEK5D5z%Std}e%b-ak^&?gNA=E7^;RR)~& zwNO_!jWqzU;wZ&A2b2TF)|&Pibo2nGsb$Yf&1D3C>VlFoH`UlJt4dXXvb-coOSriJ z{A04Yr+5m`7df^pP~5ob2;~a_w&T29b4YWIT)~P#i8=&fQhbiEIq&~2Hupssf^3PaMd&c;O0~q3Nv2RE$Q)|jg9(m6Lzpp= zE-^N8xnx`IE|r=i{rk%-pQ}t9W715Jq6!Fr=0@{%7< z)e2ewAoIprqhCXT3qRGh?dXcIO>4HbgDb1ir>6JCAu&cbfDoTBu>dn(AiLW+g zTEQjo^%}xTiUZ;Lumz+9kot@{MhwuNBi9T&|5?(m)95jE7zM$z8h{x{$3(KmDg#hFy;x^JIuQ!mumq5W=WkXDA5 zRcK3jl4t`bmuy9{?b+QHgv%q!IjPaja;!oVVjVW3Js}oB zQIPQ0ZT~vrCy?L@n_7}zyFDU=LEjguRBPICgEPwpX|-0NL51swWvR9=c0%o`JT>NA zgzq;fvG5%(+T8eG%|X+FVc9)G(LxZ)dQs)&(C;+vJv1VAi=c84xbVQ}xw{74wGnSj zFKKWuwn6Wa4s$%%{op5>y{66G-&K~zTt(kPc(nc(^r^=Z)c+FsDz}U)C+U2p3JuQg zdJny06ofcy{_fbx7bK%{`)yDca0l#RTf)8_4dQ$q0kYW9Pn%m_i)&ZE)gZ~0)fu!g zFURg0oVn7!u??}phC+_s>jgOf!Y7CR{AY0J-~jpInsk;*UDc(rw0jSKa+7Ue@X(Ma z?umm2&$R3c)yKjJX=ziDYCB|ml0?>#p-VSips7LE148x^{3mJK5r(|7T^Q1zCN%Wh zcdO{pOC(WsWHyx&|4lb8JAY0xus(5>~+g4M>JRD zEcXypo*R2AB*y>&2V38b6V+1jR7Pj-PX5XJvN!UG46{BGwWw;C-NF$lg*$CH0J#ea zfK!Rb?SD6-dxHeYW_{mo(MpAh=)0DW=db zSvzh~-L_5Hhw4IfN&(ToM_^QcfYB^ z)B=N@dNTWvbk3X=X%09@+!#J#wd=QSE17vw6bXbs7+T#@RDv(ADGiMMiUoE+lLK`7 z#Ekoedb!q zSH7jpq?2;b05WggxlW+nKx&>-T=U7<9c);cc?bpHc*Fokxk+%76%`Q42S!kmnOWaoFAF>;bz8!-CE1-1KVbR+KXFS(Tu*mV++>icnjgjFS^^dT}z5noG(hi zavz}2^>CRo9tGzD6r&ahynA3E#-C8vy)L7soeP-BG6`Vh4y0}s5ZvhNr!78GEPnJ%A^}B}G`?_V3_BL^` zS4oJDC8@6 zlJ_RhF*DE3Sx&ND`2fBThxH zBr~e0;iK>8m zzzd{c`hw}9Rz}<^VmV@4|?IHhY-TgZanH +// +// affine-js/sandbox: capability-restricted import-object tests. +// +// Run: deno test packages/affine-js/sandbox_test.js +// +// Fixtures are hand-rolled wasm binaries (same approach as loader_test.js's +// wasmWithCustomSection): a type section with one () -> () type, an import +// section declaring exactly the imports under test, and — for the live +// call-through test — a function that calls import 0, exported as "poke". + +import { assertEquals, assertThrows } from "jsr:@std/assert@1"; +import { + buildSandboxedImports, + CapabilityError, + defineCapabilities, +} from "./sandbox.js"; + +// ── wasm fixture builders ───────────────────────────────────────────────────── + +function uleb(n) { + const out = []; + do { + let b = n & 0x7f; + n >>>= 7; + if (n !== 0) b |= 0x80; + out.push(b); + } while (n !== 0); + return out; +} + +function section(id, content) { + return [id, ...uleb(content.length), ...content]; +} + +function name(s) { + const b = [...new TextEncoder().encode(s)]; + return [...uleb(b.length), ...b]; +} + +/** One import entry. kind: "function" | "memory" | "table" | "global". */ +function importEntry(mod, nm, kind) { + const desc = { + function: [0x00, ...uleb(0)], // type index 0: () -> () + table: [0x01, 0x70, 0x00, ...uleb(0)], // funcref, min 0 + memory: [0x02, 0x00, ...uleb(1)], // min 1 page + global: [0x03, 0x7f, 0x00], // i32 immutable + }[kind]; + return [...name(mod), ...name(nm), ...desc]; +} + +/** Module that just declares imports (never needs to instantiate). */ +function moduleWithImports(entries) { + const typeSec = section(1, [0x01, 0x60, 0x00, 0x00]); // 1 type: () -> () + const importSec = section(2, [ + ...uleb(entries.length), + ...entries.flatMap(([m, n, k]) => importEntry(m, n, k)), + ]); + return new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...typeSec, ...importSec, + ])); +} + +/** Module importing fn `mod.nm` and exporting "poke" () that calls it. */ +function moduleCallingImport(mod, nm) { + const typeSec = section(1, [0x01, 0x60, 0x00, 0x00]); + const importSec = section(2, [0x01, ...importEntry(mod, nm, "function")]); + const funcSec = section(3, [0x01, 0x00]); // 1 func of type 0 + const exportSec = section(7, [0x01, ...name("poke"), 0x00, ...uleb(1)]); + const body = [0x00, 0x10, 0x00, 0x0b]; // no locals; call 0; end + const codeSec = section(10, [0x01, ...uleb(body.length), ...body]); + return new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...typeSec, ...importSec, ...funcSec, ...exportSec, ...codeSec, + ])); +} + +// ── shared registry ─────────────────────────────────────────────────────────── + +const noop = () => {}; +const CAPS = defineCapabilities({ + "dom.read": { env: { dom_query_selector: noop, dom_str_eq: noop } }, + "dom.write": { env: { dom_create_element: noop, dom_append_child: noop } }, + "io.stdout": { wasi_snapshot_preview1: { fd_write: noop } }, +}); + +// ── defineCapabilities validation ───────────────────────────────────────────── + +Deno.test("defineCapabilities rejects a non-function member", () => { + assertThrows( + () => defineCapabilities({ bad: { env: { oops: 42 } } }), + TypeError, + "not a function", + ); +}); + +// ── fail-closed at load ─────────────────────────────────────────────────────── + +Deno.test("ungranted function import rejects the whole guest at load", () => { + const mod = moduleWithImports([ + ["env", "dom_query_selector", "function"], + ["env", "dom_delete_everything", "function"], // never defined anywhere + ]); + const err = assertThrows( + () => + buildSandboxedImports(mod, { capabilities: CAPS, grant: ["dom.read"] }), + CapabilityError, + "dom_delete_everything", + ); + assertEquals(err.violations.length, 1); + assertEquals(err.violations[0].name, "dom_delete_everything"); +}); + +Deno.test("defined-but-ungranted capability still rejects (grant is the gate)", () => { + const mod = moduleWithImports([["env", "dom_create_element", "function"]]); + assertThrows( + () => + buildSandboxedImports(mod, { capabilities: CAPS, grant: ["dom.read"] }), + CapabilityError, + "dom_create_element", + ); +}); + +Deno.test("all violations reported at once, not first-only", () => { + const mod = moduleWithImports([ + ["env", "a", "function"], + ["env", "b", "function"], + ]); + const err = assertThrows( + () => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }), + CapabilityError, + ); + assertEquals(err.violations.map((v) => v.name), ["a", "b"]); +}); + +// ── non-function imports ────────────────────────────────────────────────────── + +Deno.test("imported memory is refused by default (L13 red flag)", () => { + const mod = moduleWithImports([["env", "memory", "memory"]]); + const err = assertThrows( + () => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }), + CapabilityError, + ); + assertEquals(err.violations[0].kind, "memory"); +}); + +Deno.test("allowMemoryImport waives only the memory refusal", () => { + const mod = moduleWithImports([["env", "memory", "memory"]]); + const { requested } = buildSandboxedImports(mod, { + capabilities: CAPS, + grant: [], + allowMemoryImport: true, + }); + assertEquals(requested.length, 1); +}); + +Deno.test("imported table/global are refused even with allowMemoryImport", () => { + const mod = moduleWithImports([ + ["env", "t", "table"], + ["env", "g", "global"], + ]); + const err = assertThrows( + () => + buildSandboxedImports(mod, { + capabilities: CAPS, + grant: [], + allowMemoryImport: true, + }), + CapabilityError, + ); + assertEquals(err.violations.map((v) => v.kind), ["table", "global"]); +}); + +// ── least privilege + audit ─────────────────────────────────────────────────── + +Deno.test("import object carries only requested imports; audit names the capability", () => { + const mod = moduleWithImports([ + ["env", "dom_query_selector", "function"], + ["wasi_snapshot_preview1", "fd_write", "function"], + ]); + const { importObject, audit, granted } = buildSandboxedImports(mod, { + capabilities: CAPS, + grant: ["dom.read", "dom.write", "io.stdout"], + }); + // dom.read also defines dom_str_eq and dom.write is fully unrequested: + // neither may leak into the instance surface. + assertEquals(Object.keys(importObject.env), ["dom_query_selector"]); + assertEquals(Object.keys(importObject.wasi_snapshot_preview1), ["fd_write"]); + assertEquals(granted, ["dom.read", "dom.write", "io.stdout"]); + assertEquals( + audit.map((a) => `${a.module}.${a.name}<-${a.capability}`), + [ + "env.dom_query_selector<-dom.read", + "wasi_snapshot_preview1.fd_write<-io.stdout", + ], + ); +}); + +// ── registry hygiene ────────────────────────────────────────────────────────── + +Deno.test("unknown grant name is a TypeError (authoring error, not guest's fault)", () => { + const mod = moduleWithImports([]); + assertThrows( + () => buildSandboxedImports(mod, { capabilities: CAPS, grant: ["nope"] }), + TypeError, + 'unknown capability "nope"', + ); +}); + +Deno.test("two granted capabilities defining the same import differently is loud", () => { + const mod = moduleWithImports([]); + const clashing = defineCapabilities({ + a: { env: { f: () => 1 } }, + b: { env: { f: () => 2 } }, + }); + assertThrows( + () => + buildSandboxedImports(mod, { capabilities: clashing, grant: ["a", "b"] }), + TypeError, + "different implementations", + ); +}); + +// ── live call-through ───────────────────────────────────────────────────────── + +Deno.test("granted guest instantiates and calls through to the host fn", () => { + let hits = 0; + const caps = defineCapabilities({ + score: { env: { game_add_point: () => hits++ } }, + }); + const mod = moduleCallingImport("env", "game_add_point"); + const { importObject } = buildSandboxedImports(mod, { + capabilities: caps, + grant: ["score"], + }); + const inst = new WebAssembly.Instance(mod, importObject); + inst.exports.poke(); + inst.exports.poke(); + assertEquals(hits, 2); +}); + +Deno.test("the same guest without the grant never instantiates", () => { + const mod = moduleCallingImport("env", "game_add_point"); + assertThrows( + () => buildSandboxedImports(mod, { capabilities: CAPS, grant: [] }), + CapabilityError, + "game_add_point", + ); +});