# Security model & threat model
`submilli-wasm` is built to run **untrusted, mutually-distrusting WebAssembly guests** ("tenants") in a
single process. This document is the threat model: the trust boundary, what the interpreter guarantees and
how each guarantee is enforced, what it explicitly does **not** guarantee, and the configuration an embedder
**must** apply for safe multi-tenant operation.
It describes guarantees that exist in-tree today; every claim below maps to a concrete mechanism and test.
Design rationale lives in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (§7 run loop, §8 instantiation, §14 GC).
## 1. Scope & trust boundary
| Guest wasm module (tenant code) | **Untrusted** — adversarial on every wasm-reachable path |
| The embedder (host application) | Trusted |
| Host functions the embedder installs | Trusted (must contain their own logic; see below) |
| Curated/vetted wasm packages the embedder compiles as trusted | Trusted (higher complexity limits) |
| `Engine` / `Config` | Trusted (the embedder owns it) |
**The isolation unit is one `Store` (+ its `Linker`) per tenant.** Type *identity* is shared engine-wide
(so cooperating modules can exchange GC types), but GC *objects* and all entity handles
(`Func`/`Memory`/`Global`/`Table`/`Tag`/`Instance`) are **store-bound** — a handle from one tenant's store
cannot be used against another's.
The interpreter is a stack machine with **no JIT**: guest code never produces native machine code. This
removes the largest class of runtime-isolation bugs (codegen miscompiles, speculative type-confusion
gadgets) by construction.
## 2. What we guarantee
Each guarantee lists its enforcing mechanism and the test that backs it.
### Spatial memory isolation — no out-of-bounds access
Every linear-memory, table, and GC-object access is a software bounds check in **safe Rust**. The tree
contains **zero `unsafe`** operations (the only `unsafe` tokens are two wasmtime-API-parity `unsafe fn`
signatures — `Module::deserialize[_file]` — with no unsafe body). This is CI-gated two ways: the
`unsafe_code` lint under `cargo clippy -- -D warnings`, and `scripts/check-no-unsafe.sh` (which also rejects
a real `unsafe {}` smuggled in under an `#[allow(unsafe_code)]`). There is no OOB read/write to exploit; an
out-of-range access is a deterministic `Trap`, not undefined behavior.
### No cross-tenant memory disclosure — zero-on-allocation
A guest can never read memory it did not write — not another tenant's freed heap, not allocator residue,
not a recycled buffer. Every guest-visible allocation is zero/default-initialized before the guest can
observe it: linear memory (`resize(n, 0)`), tables (`resize(n, init)`), locals (`Val::default_for`), GC
objects (zeroed then field-filled), operand cells. No uninitialized fast-path exists, and because
`set_len`/`MaybeUninit`/`with_capacity`-then-expose all require `unsafe`, the zero-`unsafe` invariant
forbids them structurally. Tested by `tests/zero_alloc.rs` (grown memory reads zero, a fresh store reads
zero after a prior store dirtied+dropped its own, table slots null, defaulted aggregates).
### Bounded CPU — no hang
Fuel (charged per executed op, deterministic) and/or epoch interruption interrupt **any** guest loop,
including tight host-call-free loops (the charge/check sits on every back-edge). At least one must be
enabled for untrusted code. Both also bound the `start` function and active-segment initializers, which run
*during* `Instance::new` (see §4). Tested by `tests/instantiation.rs` (fuel/epoch interrupt `start`) and
`tests/api.rs`.
### Bounded memory, stack, and allocation — no OOM-DoS
- The installed `ResourceLimiter` gates every growth/allocation/count path: `memory.grow`, `table.grow`,
instance/memory/table counts at instantiation, and GC-heap allocation.
- `Config::max_wasm_stack` (bytes) bounds recursion → `Trap::StackOverflow`, including across the host↔wasm
re-entry boundary, so host/guest ping-pong traps rather than aborting the native stack (#30).
- Validation-time `Config::max_module_bytes` and `Config::max_expanded_locals` bound the *compiler*
against a hostile module before it runs (#32), on top of `wasmparser`'s per-dimension limits.
- **With no limiter installed, the defaults are finite ceilings, never "unbounded"** — a deliberate
deviation from wasmtime (see §5).
Tested by `tests/stack_limit.rs`, `tests/validation_limits.rs`, `src/store/limits_tests.rs`,
`tests/gc_collect.rs`.
### Panic-freedom on validated input
No validated guest module can panic the interpreter (a panic would be a whole-process DoS). The exec hot
path is clippy-gated (`panic`/`todo`/`unimplemented`/`indexing_slicing` denied, with documented
post-validation carve-outs), and the fuzzer is the real net (§6). **Host-function panics are contained** at
the call boundary (`catch_unwind` → restore store state → re-raise, matching wasmtime), and the engine's
shared type-registry lock is poison-recovering, so one tenant's host-fn panic cannot poison the shared
engine or other tenants. Tested by `tests/panic_safety.rs`.
This containment requires unwinding. With `panic=abort` (the default for
`wasm32-unknown-unknown`), a host-function panic aborts the outer Wasm execution;
host functions must return errors instead of panicking. Guest traps remain normal `Result` errors.
### Store isolation — cross-store handle misuse is caught
A handle (`Func`/`Memory`/`Global`/`Table`/`Tag`/`Instance`) minted by store A and used against store B is
detected and faults, rather than silently resolving to the wrong entity. This is never UB (it was already
memory-safe; this makes the isolation breach *observable* instead of silent). Tested by `tests/isolation.rs`.
### Type-confusion safety
Module-relative (decoder-local) type indices and engine-canonical type ids are kept strictly separate;
every runtime type check (`call_indirect`, `ref.test`/`ref.cast`, `struct.*`/`array.*`) compares canonical
ids. This is the **CVE-2024-12053** class, regression-tested in `tests/isolation.rs` (two modules whose
matching type sits at different relative indices link via canonical id; a same-index decoy does not).
## 3. What we do NOT guarantee
- **Side channels (timing / Spectre / microarchitectural).** An interpreter is far less exposed than a JIT
— there is no guest-controlled generated code — but submilli is **not** formally isolated against timing
or microarchitectural side channels and makes **no constant-time guarantees**. A tenant can observe coarse
timing of shared resources. Do not use it as a boundary against confidential-data side-channel leakage.
- **Deterministic finalization.** `externref` payload `Drop` and GC-object reclamation run at
collection time (non-moving mark-sweep), not deterministically. Host-side `Drop` timing/ordering is not a
contract to depend on.
- **Transactional instantiation.** A failed `Instance::new` is **not** rolled back — entities allocated
before the failing step linger in the store until it is dropped (matching wasmtime). Mitigation is the
per-tenant short-lived-store stance in §4.
- **wasmtime-compatible fuel.** Fuel is deterministic within submilli but uses a different per-op cost model
than wasmtime; do not compare fuel counts or fuel-trap outcomes across engines.
- **Real-time / wall-clock bounds.** None. Use epoch interruption from a watchdog for wall-clock deadlines.
## 4. Required embedder configuration (multi-tenant)
For untrusted, multi-tenant operation the embedder **must**:
1. **One `Store` + one `Linker` per tenant.** Treat the `Store` as the isolation/resource unit. **Discard it
on any instantiation or execution failure** — do not reuse it (failed instantiation is not rolled back).
2. **Install a `ResourceLimiter`** sizing memory/table/instance/GC-heap caps to the tenant's budget. Do not
rely on the finite-but-large no-limiter defaults for production.
3. **Enable fuel and/or epoch interruption** (at least one is mandatory for untrusted CPU bounding) and
**arm them before `Instance::new`** — guest code runs at instantiation (`start` + active segments), not
only on the first export call.
4. **Set `Config::max_wasm_stack`** to bound recursion depth.
5. **Set `Config::max_module_bytes` / `max_expanded_locals`** to the untrusted-tier ceilings for guest modules. Compile only
curated/vetted packages with the higher limit via `Module::new_with_limits`.
6. **Capability-scope imports.** Expose only the host functions a tenant is authorized to call. Host-fn
panics are contained, but a panic still aborts that tenant's call — keep host functions robust.
7. **Never pass handles or GC references across tenant stores** (it faults, but don't rely on the fault as a
feature).
## 5. Security-relevant deviations from wasmtime
submilli aims for drop-in wasmtime API compatibility; these *behavioral* deviations are security-motivated
and documented:
- **Finite no-limiter ceilings.** With no `ResourceLimiter` installed, memory/table growth and initial size
are bounded by finite default ceilings (notably `memory64`/`table64`, which wasmtime leaves effectively
unbounded). The no-limiter default is never "unbounded". (#31)
- **`Config::max_module_bytes` / `max_expanded_locals`.** Additive validation-time caps with no wasmtime
analog, bounding compiler memory against a hostile module: the encoded size, and the module-wide
locals arena (which `wasmparser` bounds only per function). (#32)
- **Collector selection.** `Collector::Auto`/`MarkSweep` run the non-moving mark-sweep collector;
`Collector::Null` is allocate-only; `DeferredReferenceCounting`/`Copying` are **rejected** at
`Engine::new`. (#27g)
- **Funcref-value store binding.** Named handles are store-checked; a funcref obtained as a *value* (e.g. a
`Func::call` result) carries no store check, since funcref values are same-store by construction. (#34)
## 6. How these guarantees are verified
- **Spec conformance:** the WebAssembly testsuite (36,789 assertions, 0 failures; +SIMD under `--features
simd`).
- **Targeted tests:** `tests/{stack_limit,validation_limits,instantiation,panic_safety,zero_alloc,isolation,
gc_collect}.rs` and `src/store/limits_tests.rs`.
- **Fuzzing** (`fuzz/`, #35): a `validate` target (arbitrary bytes never panic, only `Err`), an `interpret`
target (`wasm-smith` modules run fuel-bounded without panic/hang), and a `differential` target comparing
results and trap categories against wasmtime.
- **CI gates:** `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`scripts/check-no-unsafe.sh` (zero-`unsafe`), and `cargo fuzz build`.