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), but module resolution and the filesystem capability are root-jailed by default (DECISIONS D25).esrunloads modules throughNodeModuleLoaderand servesruntime:fsfromSystemFileSystem; both confine every real (canonicalized) path to the detected project root — the nearest ancestor of the entry containingnode_modules/package.json, else the entry's directory — and reject a path that escapes it via..or a symlink whose realpath leaves the root (enforced bypath::within_root; covered by jail tests indefault-providersand a../escape.txtrejection test inruntime-cli). So a granted capability's reach is the project root, not the whole filesystem. Residual nuances: (a) within that root, the trusted-mode all-capabilities grant has full reach —esrunis a runner for trusted local code, not a sandbox for hostile code; (b) legitimate cross-root setups (workspaces,pnpm link, a symlinked external store) need the relax flag (additional allowed roots), which is the still-deferred CLI part of D24/D25; and (c) the strictFsModuleLoader— an embedder-only alternative thatesrundoes not use — is unjailed by design, so an embedder choosing it must add its own confinement. An embedder sandboxing untrusted code should still withholdFileSystem/Netoutright rather than rely on the jail alone.- 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.