ES-Runtime is a security-hardened, embeddable JavaScript runtime (Layer A). This
document records the project's security posture and any known, accepted gaps
that are tracked for revisit. Architectural guarantees are specified in
docs/SPEC.md §4 and the rationale in docs/DECISIONS.md. A full threat model,
attack-surface enumeration, and residual-risk register is in
docs/SECURITY-REVIEW.md.
Until a formal channel is published, report suspected vulnerabilities privately to the maintainer rather than via public issues.
The resource-limit / FFI-safety spine (SPEC.md §4) is in place as of Phase 9:
- Heap limit — a near-limit guard terminates execution before the host OOMs.
- Execution watchdog — a thread-safe
InterruptHandleterminates a runaway script; it surfaces asError::Terminated, never a hang (esrun --timeout). - Stack guard — V8-native; deep recursion is a catchable
RangeError. - Bounded pending-ops — adversarial JS can't pile up unbounded host work.
- Panic containment — op/timer/reject callbacks are
catch_unwind-wrapped, so a host panic is a JS exception, not an unwind across the FFI (assumespanic = "unwind"). - Deny-by-default capabilities; deterministic providers for reproducibility.
Not yet hardened (later Phase 9): cargo-fuzz (URL/streams/encoding/the
marshaler), sanitizer CI (Miri/ASAN), a WPT/min-common conformance run, a
systematic intrinsic-integrity (prototype-pollution) audit, and an external
security review. Until those land, do not run hostile/untrusted code with
esrun, --deny-all or not; the embeddable library lets an embedder restrict
capabilities and inject its own providers.
esrun grants every capability by default — it runs a local script the user
named. Restriction is opt-in (DECISIONS D38), and there are exactly two modes,
which cannot be combined:
esrun --deny-net --deny-run app.js # everything, minus these
esrun --deny-all --allow-imports --allow-net app.js # nothing, plus these| Mode | Baseline | Direction |
|---|---|---|
--deny-<name> |
everything granted | subtractive only |
--deny-all --allow-<name> |
nothing granted | additive only |
--allow-<name> requires --deny-all: with everything already granted there is
nothing for it to add. Because neither mode mixes directions, no flag ever
overrides another — a reader goes top to bottom and the list is the answer.
The eight names are read, write, imports, net, listen, env, run,
signals — the same words the runtime:process permissions API and the
denial message use. A denied operation throws NotAllowedError
(ERR_CAPABILITY_DENIED) before the effect, never a partial one.
The parser is strict on purpose: a space-separated value, a permission flag placed after the script, or an unknown name is an error. Each of those, ignored, would leave a run wider than the command line claims.
Two things --deny-all does not do, both deliberate:
- It still runs the entry file. That file is read by the CLI before a
runtime exists, so it is outside the capability system — the user named it,
and the flags govern what it may then do. Since
--deny-allincludes--deny-imports, a fully denied run is a single-file run; add--allow-importsfor an application with dependencies. - It does not revoke
Clock/Entropy/Timers/TaskSpawn. No op gates them; a denied script still computes, it just reaches nothing.
Importing a runtime: module always works, under any policy — the gate is the
op, not the import. A program can ask what it is allowed to do:
import { permissions } from "runtime:process";
permissions.denied; // ["read", "write", ...]
permissions.has("net"); // falseSeven of the eight can be scoped to a list. (imports is the exception —
what may be loaded has its own mechanism, below.)
esrun --deny-all --allow-imports --allow-env=PORT,DATABASE_URL \
--allow-net=db.internal:5432 --allow-listen=8080 \
--allow-read=./data --allow-write=./out --allow-run=git \
--allow-signals=SIGTERM server.js--allow-net=<hosts>refuses every other address, on every redirect hop as well as on the request the program wrote — the check that stops a compromised dependency exfiltrating over the app's own legitimate network access. Hosts are matched as written, before resolution, and exactly:example.comdoes not admitapi.example.com, and there are no wildcards.--allow-listen=<addresses>refuses every other bind, before the port is claimed. A separate list fromnet: reaching out and being reachable are separate capabilities.--allow-env=<names>narrows the environment to those variables — every other one is absent, so a guest can neither read it nor learn its name.--allow-run=<programs>refuses to spawn anything else.--allow-read=<paths>/--allow-write=<paths>refuse every other path. Entries are resolved against the working directory and cover their subtree, and the check runs after canonicalization, so a symlink inside an allowed directory cannot name a file outside it. The two are separate lists, and both governruntime:fsandruntime:wasialike.--allow-signals=<names>refuses to watch anything else, and hides the rest fromsignals(). A watch suppresses the default action, so this is the privilege to decline to die on request, granted one signal at a time.
A refusal is ERR_PERMISSION_DENIED — a scoped denial, distinct from the
ERR_CAPABILITY_DENIED a missing capability raises. A scoped grant still
reports permissions.has("net") === true: the capability opens the door, the
list is what the provider withholds.
A value on a flag that could not enforce it would still be rejected rather than ignored — the rule outlives the capabilities it was written for. The filesystem root jail (D25) is always on regardless, for both paths: a path list narrows it and never widens it, so an entry outside the project root is not a way out of it.
Capabilities bound what running code may reach. What may become running code is a separate question, and has a separate mechanism (DECISIONS D39) — a JSON file, named explicitly and never auto-discovered:
esrun --deny-all --allow-imports --allow-net=db.internal:5432 \
--import-policy=./import-policy.json server.js{ "allow": ["./src", "express", "@acme/ui"], "deny": ["aws-sdk"] }An entry beginning with . or / is a path covering its subtree; anything else
is a package name — the split the loader already makes between a relative and a
bare specifier. Deny wins over allow. Omitting "allow" permits everything
not denied; an empty "allow": [] is an error, not a run that can load nothing.
Unknown keys are an error, for the same reason an unknown flag is: a misspelled
"allowed" would read as protection that is not there. Paths resolve relative
to the policy file, so a committed policy means the same thing wherever the
run is invoked from.
Matching runs on the resolved, canonicalized module, after the root jail, so a symlink cannot name its way in and a pnpm store path is still recognisably its package. A package entry covers that package's own files and not the packages it imports — each is named in its own right, so a dependency that quietly pulls in another cannot load it. The entry file is exempt: it is read before a loader exists.
The two layers do not substitute for each other. The imports capability
decides whether the loader runs at all; the policy decides what it may resolve.
A policy is not a way around --deny-imports — under --deny-all, an allow
entry still loads nothing.
Known gap: no integrity. A policy names packages and paths, not content.
"express" says the loader may resolve that package; it says nothing about
which version, or whether the bytes are the ones you audited. Lockfiles remain
the install-time counterpart; content pinning is future work. Treat the policy
as a bound on which dependencies can run, not as proof of what they are.
Spawning is the one grant that ends the sandbox, and it is treated as such (DECISIONS D37):
Runis never implied by another capability. A child process runs outside every confinement here — no capability check, no filesystem root jail, no execution watchdog reaches it. GrantingRunto guest code is granting everything the host user can do. Withhold it from anything untrusted.- No shell.
runtime:systemhas noexec, noshell: true, and no template form. A command is a program plus an argv, so a guest-supplied argument reaches the child as data and can never become a second command. On Windows,.bat/.cmdfiles are refused rather than run through the command interpreter (CVE-2024-27980). - No inherited environment. A child gets exactly the
envit is passed. Inheriting is opt-in (inheritEnv: true) and additionally requiresEnv, so a runtime grantedRunalone cannot launder the host's environment out through a child. A maskedSecretis unwrapped only on its way into a child's environment, never into a log. - Policy belongs to the provider. An embedder that must grant
Runcan still bound it:SystemCommands::with_allowlist(["git", "ffmpeg"])andwith_max_children(n)are enforced in Rust, below the capability check. - No orphans. Children still running when the runtime is torn down are killed, not reparented.
Not covered: killing a process tree. kill() signals the direct child
only, so a child that spawns its own children can leave grandchildren running.
.env support (DECISIONS D30) is built so that what the guest can read from
the environment is an explicit host decision, and so that secret values resist
accidental disclosure:
- No implicit disk reads. A single
.envfile is loaded only via an explicitesrun --env-file=<path>. There is no auto-discovery of a.envin the working directory or project root — nothing on disk is read into the guest's environment unless you ask for it. This is a CLI/host feature; the embeddable library never loads env files and never mutates the real process environment (the file values are an in-memory overlay onruntime:process). - OS environment wins by default. Loaded values fill only keys the OS does
not already set, so a checked-in
.envcannot silently clobber a production deployment's real configuration.--env-overrideopts into letting file values win. - Secret masking. Env entries with a secret-bearing key (case-insensitive)
— ending in
_KEY(S),_TOKEN(S),_SECRET(S),_PASS,_PASSWORD(S), or containingCREDENTIAL/AUTH(as an underscore-delimited word) — are exposed byruntime:processas an opaqueSecretthat renders as"[redacted]"inconsoleoutput, string coercion / template literals, andJSON.stringify. The real value is held in a module-privateWeakMapand is obtainable only via the explicitunmask(value)helper. Scope: this defends against accidental leakage to logs and serialized output — it is not a barrier against hostile guest code, which can callunmaskitself (the guest is already trusted with the value). Parser errors never include a variable's value.
The security boundary is in Rust, not in JavaScript. The op table and the
capability set live in the engine's OpState; every capability-gated op is
checked there before dispatch. Consequently, no amount of guest JS tampering —
polluting Object/Array.prototype, reassigning or deleting globals, or trying
to forge globalThis.__ops — can grant a capability or dispatch an op the host
did not register and gate. This is covered by tests (capability_gate_survives_js_tampering,
op_table_binding_is_locked, op_dispatch_survives_prototype_pollution).
As defense-in-depth on the JS surface, harden.js (the last prelude fragment)
locks the globalThis.__ops binding (non-writable/non-configurable, while the
object stays extensible so the host can still register ops) and freezes the
runtime's plain namespace objects (console; crypto/performance are frozen
at definition).
Deliberately deferred — SES-style primordial hardening. Freezing the JS
primordials (Object.prototype, Array.prototype, …) would protect the
prelude's own correctness against pollution, but it is an opinionated policy
with real guest-compatibility cost. It is left to the embedder / Layer B rather
than baked into a general-purpose Layer A. Until an embedder opts in, a guest
that pollutes primordials can break the prelude's JS behaviour for itself — it
still cannot escalate privilege past the Rust boundary.
Every change must pass cargo deny check and cargo audit in CI (docs/SPEC.md
§5). Advisory exceptions are never silenced globally: each is listed explicitly,
with a rationale, in both deny.toml and .cargo/audit.toml, and is
revisited rather than forgotten.
- What.
crypto.subtleRSA (RSASSA-PKCS1-v1_5, RSA-PSS, RSA-OAEP) is backed by the RustCryptorsacrate (docs/DECISIONS.mdD9). That crate carries RUSTSEC-2023-0071, a medium-severity (5.9) timing sidechannel in RSA private-key operations. No fixed upgrade exists — the issue is architectural in RustCrypto's RSA and has been open since 2023. - Why accepted (maintainer, 2026-06-12). RSA private-key operations run
host-side; a sandboxed guest does not get a high-resolution local timing
oracle against them, which lowers practical exploitability. The alternatives
were weighed and each costs more than it buys for this project:
- aws-lc-rs (constant-time) draws randomness from its own internal OS
CSPRNG with no hook for the injected
Entropyprovider — breaking the runtime's "no ambient authority / all I/O injected" thesis for RSA — and adds a C/assembly crypto backend to the otherwise pure-Rustruntimecrate. - openssl-rs adds a system OpenSSL dependency, regressing the portable, self-contained build goal (SPEC §1, D2).
- aws-lc-rs (constant-time) draws randomness from its own internal OS
CSPRNG with no hook for the injected
- Mitigations in place. All RSA randomness (key generation, PSS salt,
PKCS#1 v1.5 blinding, OAEP padding) is routed through the injected
Entropyprovider — never ambientOsRng— preserving determinism under seeded providers and capability control. RSA is capability-gated like all ofcrypto.subtle. - Revisit when. RustCrypto ships a constant-time RSA, or the
elliptic-curve0.14 /digest0.11 generation reshapes the stack such that a vetted, constant-time, provider-routable backend becomes available.
Informational (unmaintained, not a vulnerability). Reaches us only transitively
through the v8 crate; not a direct dependency and not removable without an
upstream v8 change. Revisit when v8 drops it.