A consolidated threat model, attack-surface enumeration, and residual-risk
register for ES-Runtime (Layer A). This is an internal review by the
implementers; it is not a substitute for an external audit (which is an
outstanding pre-1.0 item — see §6). The user-facing posture and reporting
channel live in the top-level SECURITY.md.
Status: pre-1.0. The resource-limit / FFI-safety spine is implemented and
tested (SPEC §4); fuzzing, sanitizer CI, and an external review are not yet
done. Do not run hostile or untrusted code in production yet.
| Actor | Trust | Notes |
|---|---|---|
| Embedder / host | Trusted | Constructs the runtime, grants capabilities, supplies providers, drives the loop. Owns the security policy. |
| Guest JavaScript | Untrusted / potentially hostile | The code the runtime executes. The whole design assumes it may be adversarial. |
| Providers | Host-supplied, trusted-but-fallible | Clock/Entropy/Net/Console/Timers. The host vouches for them; they may still fail (errors are typed) but are not treated as adversarial. |
| V8 | Trusted, large | The JS engine. Memory-safe within its own model; the FFI surface to it is the main unsafe locus, confined to engine. |
The core property: the security boundary is in Rust, not in JavaScript. The
op table and the capability set live in the engine's OpState. Guest JS runs in
a realm it can mutate freely, but it cannot reach across the op boundary except
through host-registered, capability-gated ops.
- The op boundary (JS → Rust). Guest JS calls
globalThis.__ops.<name>, which dispatches (in Rust) to a host handler. Every call is checked against the capability set before the handler runs (capability-check-first). - The capability gate. Deny-by-default
CapabilitySetinOpState; only the embedder grants capabilities. No ambient authority, no JS-reachable escalation path. - Provider injection. All side effects (time, randomness, network, console,
timers) flow through injected provider traits — there is no direct OS access
from
runtime/engine. Real I/O exists only indefault-providers. - The FFI /
unsafesurface. All V8 interaction and allunsafeis confined toengine(#![forbid(unsafe_op_in_unsafe_fn)]workspace-wide;runtime/common/providersare#![forbid(unsafe_code)]).
| Surface | Threat | Defense | Status |
|---|---|---|---|
| Hostile guest JS | CPU exhaustion (infinite loop) | Execution watchdog (InterruptHandle::terminate from another thread) → Error::Terminated |
☑ tested |
| Heap exhaustion (heap bomb) | Near-heap-limit callback terminates before host OOM | ☑ tested | |
| Stack exhaustion (deep recursion) | V8-native stack guard → catchable RangeError |
☑ tested | |
| Privilege escalation via prototype/global tampering | Boundary is in Rust; capability check in OpState |
☑ tested | |
| Unbounded pending async ops | max_pending_ops bound → RangeError |
☑ tested | |
The op ABI (__ops) |
Direct raw-op calls with bad arguments | Handlers validate marshaled args, return typed OpErrors |
☑ |
Forging / replacing globalThis.__ops |
Binding locked (harden.js); dispatch + op-id validation in Rust |
☑ tested | |
Marshaling (Value) |
Malformed/edge-case values from JS | Defensive marshaling; primitives + copied bytes only | ☑ |
| A host op handler panics | catch_unwind around op/timer/reject callbacks → JS exception, not an unwind across V8 |
☑ tested (assumes panic = "unwind") |
|
| Providers | A provider returns an error (e.g. entropy fails) | Typed ProviderError → JS exception; no partial effect |
☑ |
| A provider panics | Contained as a host-op panic (above), unless panic = "abort" |
◐ | |
| V8 / FFI | Use-after-free of handles/scopes | Pinned-scope API; handles never outlive their scope; isolate !Send |
☑ by construction |
| Rust panic crossing into C++ | catch_unwind at every V8-invoked callback |
☑ | |
| Supply chain | Vulnerable/unmaintained/incompatibly-licensed deps | Pinned versions; cargo-deny + cargo-audit CI gates; documented exceptions |
☑ |
crypto.subtle uses vetted RustCrypto primitives (DECISIONS D9): constant-time
HMAC verify, AEAD tag checks, no hand-rolled primitives. All asymmetric
randomness (RSA/EC key gen, PSS salt, PKCS#1 blinding, OAEP padding, ECDSA
nonces) is routed through the injected Entropy provider — never ambient
OsRng — preserving reproducibility under seeded providers and capability
control. Carried risk: the rsa crate's Marvin timing sidechannel
(RUSTSEC-2023-0071, no fix available); accepted because RSA private-key ops are
host-side. See SECURITY.md.
Under deterministic providers (ManualClock, SeededEntropy, …) runs are
reproducible — useful for testing and for an embedder that needs replayable
execution. Structured tracing spans surround ops and the loop; there is no
println! in library crates (lint-enforced).
- No external security review or fuzzing yet.
cargo-fuzz(URL/encoding/ streams/marshaler) and sanitizer CI (Miri on the safe core, ASAN on the FFI) require a nightly toolchain and are outstanding. This is the single biggest reason not to run untrusted code in production yet. rsaMarvin timing sidechannel (RUSTSEC-2023-0071) — accepted, no fix available (SECURITY.md / DECISIONS D9).- SES-style primordial hardening deferred. A guest can pollute
Object/Array.prototypeand break the prelude's own JS behaviour for itself; it cannot escalate privilege past the Rust boundary. Full primordial freezing is an embedder/Layer-B policy, not baked into Layer A. panic = "abort"builds. Panic containment assumespanic = "unwind". Underabort, a host-op (or provider) panic aborts the process — the chosen policy for that build, but worth stating.- Watchdog is wall-clock + interruption-point based. It stops scripts at V8 interruption points (tight loops are interruptible). It is not cycle-accurate CPU accounting, and a pathological non-yielding native path could delay termination.
- Side channels (Spectre, timing). Relies on V8's own mitigations; not separately addressed at this layer.
esrungrants all capabilities (trusted-local-script mode) and loads ES modules from the local filesystem viaFsModuleLoader. That loader does not confine resolution to a root — afile:/relative/absolute specifier may reach any readable path (no../symlink jail yet), so module loading has the same reach as the grantedFileSystemcapability.esrunis a convenience runner for trusted local code, not a sandbox for untrusted code; an embedder sandboxing modules should supply a root-confiningModuleLoaderand withholdFileSystemwhere appropriate. (Root-jailing is a tracked hardening follow-up; DECISIONS D21.)- Engine after
Terminatedis "spent." The embedder should discard a runtime whoseeval/tick returnedError::Terminatedrather than reuse it.
- Grant the minimum capabilities the workload needs; deny-by-default is the
starting point. Never grant
Net/FileSystemto untrusted code. - Set
Limits(heap,max_pending_ops) appropriately; run a watchdog thread viaRuntime::interrupt_handle()(oresrun --timeout) to bound execution time. - Inject your own providers to mediate/observe all I/O; use deterministic providers where reproducibility matters.
- Build with
panic = "unwind"to keep panic containment effective. - Consider SES-style primordial hardening in your own prelude layer if you run mutually-distrusting guest code in one realm.
- Until fuzzing + an external review land, treat this as not-yet-hardened for hostile input.