Skip to content

feat(wasm-sandbox): community parser isolation boundary (Closes #405) - #455

Open
woahwhattheheck wants to merge 22 commits into
Open-audit-foundation:mainfrom
woahwhattheheck:latch/oa-405-wasm-sandbox
Open

woahwhattheheck wants to merge 22 commits into
Open-audit-foundation:mainfrom
woahwhattheheck:latch/oa-405-wasm-sandbox

Conversation

@woahwhattheheck

Copy link
Copy Markdown

Summary

Closes #405.

Builds a real untrusted-code execution boundary for community-contributed contract parsers (not a stub wrapper). Native TypeScript blueprints stay the reviewed path; community parsers run only through the sandbox and are labeled parserProvenance: "community-wasm".

What landed

  • Threat model — lib/wasm-sandbox/WASM_SANDBOX_ARCHITECTURE.md (risk → mitigation → test mapping)
  • WasmSandboxRunner — host-provided env.memory (capped pages), worker_threads force-terminate for CPU budget, input/output size caps, typed errors
  • Zero ambient capabilities — only allowed import is env.memory; WASI / fs / Node API imports rejected pre-instantiate (host + worker)
  • Adversarial fixtures — infinite loop, memory bomb, OOB load, malformed/oversized output, WASI attempt, fs attempt
  • Registry integration — registerCommunityParserFromManifest / FromBytes, translateEventAsync / translateWithCache path, file-based example under lib/wasm-sandbox/community/
  • Authoring guide + Rust example — COMMUNITY_PARSER_GUIDE.md, examples/rust/ (wasm32-unknown-unknown)
  • npm run test:wasm — vitest node env suite

Acceptance checklist

  • Threat model document exists; each identified risk maps to a concrete, tested mitigation
  • Guest code has zero ambient host capabilities (no filesystem, network, or Node API access) — verified by tests that try and fail
  • Memory and CPU limits enforced by the host (cannot be exceeded by guest behavior)
  • Full adversarial suite passes — attacks contained; host process stays alive
  • Working example community parser documented and translates a sample event end-to-end through the registry path
  • npm run test:wasm runs the suite above

Honest gaps / follow-ups

  • Isolation uses Node built-in WebAssembly + killable workers (not Wasmtime fuel). Wall-clock timeout, not deterministic instruction fuel.
  • Each run currently spawns a Worker (pool reuse is a follow-up).
  • No dashboard upload UI (explicitly out of scope for Build the WASM sandbox for community-contributed parsers #405; file-based PR registration only).
  • Community description text is still attacker-chosen copy (sanitized, labeled community-sourced).

Test plan

npm run test:wasm

Observed locally: 13/13 passed (runner + adversarial + registry integration).

…oundation#405)

Add a real untrusted-code execution boundary for community-contributed
parsers: threat model, WasmSandboxRunner with host-capped env.memory and
worker-enforced CPU timeout, adversarial fixtures (loop/OOM/OOB/WASI/fs/
malformed output), Translation Registry integration with
parserProvenance=community-wasm, authoring guide + Rust example, and
npm run test:wasm.

Closes Open-audit-foundation#405
Copy the sandbox's CJS worker into both runtime images at the path used by
the compiled runner. TypeScript does not emit this asset, so a valid parser
previously failed with Cannot find module in the production layout.

Verified the compiled runner executes the same committed parser from both
corrected runtime layouts. The existing WASM suite passes all 17 tests.
Persist parser provenance and sandbox failures, preserve them during replay,
and prefer authoritative stored translations over stale cache entries.
Initial dashboard loads and contract searches now share a metadata adapter
and retain stored community outcomes. Show the community marker in the feed
and details, including the failure code.

Add two nullable Event columns without inferring legacy provenance. Focused
registry, persistence, dashboard and resolver checks pass 122 cases; Prisma
schema validation, client generation and the two-column migration diff pass.
Preserve the existing memory-limit and Docker worker-asset repairs.
Reuse the nine production build-repair postimages from 31972c3. The original blobs matched that repair's parent, preserving this branch's newer sandbox, Docker asset and persisted-provenance changes.

Remove the shared translation registry's runtime import of the Node-only community registry. Community registration supplies a typed async callback that delegates to the existing isolated worker. Native browser translation and server sandbox outputs retain their existing paths.

Validation on this exact twelve-file composition: npm run build passes with Node heap 512 MiB (Turbopack, TypeScript, all 22 static pages); npm run lint passes. The committed community manifest executed through the real worker and returned translated transfer/community-wasm, while synchronous translation stayed cryptic. This single execution is not a performance benchmark. No optional test sweep or full-suite success is asserted.

Original prerequisite repair: 31972c3
Operation: OA455-456-BUILD-REPAIR-REUSE-20261003-01
Carry persisted parser origin and sandbox failure codes through streamed
CSV, JSON and NDJSON, plus the existing small browser download paths.
Preserve unknown legacy metadata as JSON null and blank CSV fields.

Extend the existing export regression suite through the actual dialog
and download Blob. All 15 focused checks pass on the composed source.
Preserves the prerequisite and client boundary repair from fa32226.
Carry persisted parserProvenance and sandboxError into the signed webhook payload, with explicit nulls for legacy events. Cover community success/failure, native output, and legacy metadata through actual delivery projection and retry in the existing webhook integration suite.
Decode complete numeric ScVal payloads through the Stellar codec instead of reading the type header as an amount. Preserve signed and unsigned 32/64/128-bit values, reject malformed/non-numeric input to the existing zero fallback, and round the seven-decimal base units using bigint arithmetic.

Update the developer example to valid SDK-produced I128 XDR and exercise normalization plus custom-ABI translation in the existing amount suite. Leave missing token-registry and release helper APIs outside this repair.
Serialize input once before loading module bytes and reuse that exact JSON
for the worker. This removes duplicate serialization and prevents mutation
or stateful toJSON from replacing the payload after the input-size check.

Add focused regressions for single serialization and loading-time mutation.
Check defined table descriptors before allocation or guest start: require explicit maxima, cap their aggregate at 65,536 reference slots and permit at most 32 tables. Preserve ordinary bounded funcref/externref tables and reject unsupported encodings instead of misreading their limits.

Add the same 14 real-worker adversarial and compatibility cases to the existing Vitest suite and a dependency-free Node entrypoint. On Node 22.16.0, unchanged worker from 8ad1028: 6 pass / 8 fail; repaired worker: 14 pass / 0 fail. Syntax checks pass. No runtime mocks, dependency install, full application/registry/Vitest or compiled Rust-example run is claimed.

Update the existing authoring and architecture documents with the table ABI, reproduction and precise resource-boundary limitations. Linear-memory/table caps are not a total-process RSS or global OOM guarantee.

Rejoin cc39ac3 and preserve its independent input-snapshot runner fix and regressions unchanged. Only the five table-scope files change in this successor. Original PR and all prior contributions remain intact.

[skip ci]
A parser may return output inside its input allocation. Calling optional dealloc before get_output_len and UTF-8 decoding lets guest cleanup overwrite an otherwise valid result. Capture the bounded output string first, then perform best-effort cleanup in finally. Preserve cleanup on output-boundary failures, ignored cleanup traps and the existing translate-trap path.

Add one maintained runner regression with a 223-byte WASM fixture and matching WAT: translate writes a 63-byte JSON result in place, dealloc poisons it and grows memory from one page to two. Exact output and 131072-byte memory assertions prove output preservation and retained cleanup. Document that ABI lifetime in the existing authoring guide.

Validation: Node 24.19.0, real production runner/Worker/WebAssembly. Four focused scenarios (in-place output, cleanup trap, malformed JSON, output cap) change from 2/4 correct on worker b18aa72 to 4/4 correct. Maintained runner.test.ts passes all 9 tests on retained Vitest 5.0.1 in 616ms, using the unchanged wasm test config with only scratch cache/one-worker settings. Worker syntax check passes. No new dependencies; no full application, registry, typecheck or CI result asserted.

Preserve the prior input snapshot, memory and table-boundary repairs and the original PR455 contribution.
The manifest promises its version in schemaVersion, but registration dropped
that value and both adapter outcomes returned null. A parser declared as
2.3.4 was therefore registered as 1.0.0 and produced an unversioned result.

Copy the optional manifest label into the community blueprint, carry it
through the existing registry, and include it on successful and failed
sandbox results. Unversioned blueprints retain an absent version property
and null translated schemaVersion. Schema selection, ledger windows,
caching and native blueprint behavior are unchanged.

Extend the existing registry-integration tests for file-manifest versions,
nondefault 2.3.4 selection/translation, failed versioned execution, and the
unversioned null controls. Focused Node 24.19.0 execution of the actual
registration, resolver, runner and worker with the committed echo WASM
passes 5/5 scenarios; baseline fb9145b passed only the two unversioned
controls (2/5). File and byte registration, success and worker compilation
failure preserve status, event type, provenance and error.

This commit follows b8cc88a and preserves its concurrent output-lifetime
repair. The same five cases also pass against its actual worker blob
0514342.

The focused VM probe stubs unrelated native factories, metrics,
localization, decoders and sanitizer. The maintained Vitest integration
file, full application build and full suite were not run here; no
dependency installation or CI rerun is claimed.
Interpret the signed i32 translate result as an unsigned wasm32 address
before the existing output-range check. Reject values outside signed i32
representation before coercion, so a malformed f64 return cannot wrap into
a valid offset. Preserve output copying before guest deallocation.

The parent worker accepts -128 as a Buffer tail offset even though the
wasm32 address is 4294967168 and the memory has only 65536 bytes. A valid
173-byte module reproduced that result. Ordinary and true tail addresses
both returned the expected output. This concerns guest-memory range
validation; no host-memory disclosure or sandbox escape is claimed.

Add two maintained adversarial cases with 174/179-byte valid WASM fixtures
and their WAT sources, and document unsigned pointer interpretation.

Validation on parent f047dbf plus these source changes: Node 24.19.0;
existing runner.test.ts and adversarial.test.ts, 19 passed in 2.10 seconds.
Command: vitest run --config vitest.wasm.config.ts
  lib/wasm-sandbox/__tests__/adversarial.test.ts
  lib/wasm-sandbox/__tests__/runner.test.ts
  --maxWorkers=1 --no-file-parallelism --no-cache
Used existing installed Vitest 4.1.10 read-only; no dependency installation
or manifest/lock change. Declared project Vitest is ^3.2.7, so this is not
an exact locked-dependency or full-application/registry/typecheck CI pass.
node --check lib/wasm-sandbox/worker.cjs passed.

Seven files only; original PR Open-audit-foundation#455 and all prior source repairs preserved.
Check the original get_output_len result is an integer within the signed
i32 range before positive-length, output-cap and memory-range validation.
Return INVALID_OUTPUT for malformed values instead of wrapping a claimed
length into a small valid JSON slice.

A valid 165-byte module returning f64 4294967335 previously read 39 bytes
and succeeded. Actual Worker replay now rejects that value, fractional
39.5 and negative -4294967257; ordinary i32 length 39 remains successful.
The maintained adversarial test includes the oversized-length regression
with committed WAT and WASM fixtures.

Existing adversarial.test.ts: 11/11 pass, zero failures or skipped cases,
Node 24.19.0 and retained Vitest 4.1.10. The declared range is ^3.2.7.
The source-bound command, preliminary baseline timeout and validation
limits are recorded in COMMUNITY_PARSER_GUIDE.md. No dependency or
production timeout changes; no full-app, registry or hosted CI claim.

Preserve the preceding unsigned-pointer and output-lifetime repairs and
the original PR Open-audit-foundation#455 branch and contribution history.
Add target-specific --import-memory linking and document the existing build script's required working directory. The original Rust guest compiled but declared private memory, which the sandbox correctly rejects. The repaired module imports only env.memory and retains the host memory ceiling.

Actual Rust/Cargo 1.98.1 paired builds and the unchanged real worker passed in run 37201229337. Original 36966-byte module: FORBIDDEN_IMPORTS. Repaired 37009-byte module: expected sample transfer, 1179648 bytes reported linear memory. One-page host cap: MEMORY_LIMIT_EXCEEDED for 17 required initial pages.

Replayed the same binaries against current composed worker 69df267 at parent 08de844; all three cases passed on Node 24.19.0. Preserve the concurrent output-length validation and earlier output-pointer repair.

Only the example Cargo config and README change. Cloud controller and replay remain on an isolated validation branch. Exact build/source/run identities are documented in the README. This is compiled-guest compatibility evidence, not full-app, registry, real XDR or statistical performance evidence.
@woahwhattheheck

Copy link
Copy Markdown
Author

I am claiming consideration for any GrantFox reward applicable to my contribution in this PR for #405, payable to @woahwhattheheck. Please confirm that this PR is associated with the original contributor's campaign record and included in the reward evaluation, and confirm any allocation and payout date once approved. Existing contributors' attribution and any remaining acceptance requirements remain documented in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build the WASM sandbox for community-contributed parsers

1 participant