The Threat Model
Three concepts: the production profile, the threat table, and the non-goals.
This chapter produces ADR-013 and the document docs/sandboxing.md. It is the chapter that
decides whether Ember may use the word "sandbox" in its README, and it is the one where the most
valuable content is the list of things Ember does not defend against.
A security claim without a threat model is not a claim. It is a mood.
Concept 1: The Production Profile
1. Concept
A production profile states, in one paragraph, what the system is for and who it assumes the adversary is. Every control below is justified against it, and every non-goal is honest because of it.
2. Problem
"Is Ember secure?" is unanswerable. "Can Ember safely run a policy written by a customer's engineering team inside our ranking service?" is answerable. The first question invites hand-waving; the second invites a design.
3. Mental model
A threat model is assets × adversaries × controls, with an explicit boundary around what is out of scope. The out-of-scope list is the part that makes the in-scope list believable.
4. Implementation — the profile
Ember's intended production profile: embedded policy scripting for trusted to partially-trusted scripts inside a Rust service.
"Partially trusted" means: authored by your own engineers, your customers' engineers, or an internal tool. People who may write an infinite loop, allocate a gigabyte by accident, or make a logic error the runtime should contain. It does not mean anonymous internet-submitted code.
Ember is not suitable for hostile multi-tenant execution. It has no memory isolation beyond the process boundary, no side-channel mitigations, no fair scheduling between scripts, and a host function surface whose safety is the host author's responsibility. If you need those, you need a process boundary, a WASM runtime with a real sandbox, or a microVM — not an in-process interpreter.
Write that in the README, not only in docs/sandboxing.md. A security property that lives three
clicks deep is a property nobody evaluated.
5. Alternatives — profiles Ember could have targeted
| Profile | What it would require | Why not |
|---|---|---|
| A. Trusted scripts only | nothing; no limits at all | Too weak: an accidental infinite loop takes down a service. Limits are cheap |
| B. Partially trusted (ours) | limits, capabilities, determinism, no panics | Achievable in-process, and it matches the actual use case |
| C. Hostile multi-tenant, in-process | memory isolation, side-channel mitigation, fair scheduling, a formally-reviewed runtime | Not achievable in-process. Claiming it would be a lie |
| D. Hostile, out-of-process | a process/VM boundary around the whole engine | Achievable — and it is the host's architecture, not Ember's. Ember composes with it |
6. Decision
ADR-013: B, with D as the documented escalation path.
The escalation path matters and belongs in the docs: a host that needs C should run Ember in a
sandboxed subprocess (seccomp, or a Landlock-restricted child, or a microVM). Ember's determinism
and its lack of ambient authority make it a good payload for such a sandbox — which is a real
benefit of the design worth stating positively.
7. Tradeoffs
| We gain | We lose |
|---|---|
| A claim we can defend line by line | The word "sandbox" without qualification |
| Cheap, deterministic controls that stop real accidents | Nothing that stops a determined attacker with a runtime exploit |
| A clean composition with OS-level isolation |
8–9. Production concerns and references
- Revisit the profile when the code changes. Adding
requirefrom the filesystem, or a host function that shells out, changes the profile. The ADR should be superseded, not edited. - References: the OWASP threat-modeling material for the assets/adversaries/controls structure; the Firecracker and gVisor threat models for how a serious in-process-adjacent boundary is documented; V8's published security model for what a mature runtime does and does not claim about its own isolates.
Concept 2: The Threats, and the Controls
1–3. Concept, problem, mental model
Enumerate what a partially-trusted script can do, then name the control for each. A threat with no control is a non-goal and belongs in the next section — not omitted, and not quietly implied to be handled.
4. The table
| # | Threat | Example | Control | Lab |
|---|---|---|---|---|
| 1 | Infinite loop | while true do end | Instruction budget, in the fetch position | 11, 23 |
| 2 | Infinite recursion | local function f() return f() end | Call-depth limit, counted across native boundaries | 7, 11, 23 |
| 3 | Memory exhaustion (tables) | for i=1,1e9 do t[i]=i end | Memory budget checked in Heap::alloc | 15, 23 |
| 4 | Memory exhaustion (strings) | ("x"):rep(1e9) | Budget checked before allocating, in rep/concat/format | 16, 21 |
| 5 | Result-list amplification | f(table.unpack(huge)) | Max-results and max-arguments limits | 17, 23 |
| 6 | Compile-time bombs | 100k-deep nesting; 10 MB of source | Parser depth limit; source-size limit at the boundary | 2, 23 |
| 7 | Error amplification | an error message echoing a 10 MB string | All messages truncate script-controlled data | 24 |
| 8 | Escaping the capability set | os.execute | Nothing is registered unless granted; enumerated-surface golden test | 21 |
| 9 | Cross-evaluation state leak | a script writes a global read by the next request | Fresh globals table per evaluation | 23 |
| 10 | Hash-collision DoS | crafted table keys | Per-engine seeded hash; iteration order independent of it | 16 |
| 11 | Nondeterminism | os.time, math.random, hash-order iteration | Excluded from SAFE; insertion-ordered tables | 13, 21 |
| 12 | Panic in the runtime | any malformed input reaching an unwrap | No-panic policy; fuzzing; catch_unwind at the host callback boundary | 26 |
| 13 | Malformed bytecode | a corrupted or hostile cached chunk | The validator, run before execution | 9, 22 |
| 14 | pcall swallowing a limit | while true do pcall(...) end | Limit errors are not catchable by pcall | 21 |
| 15 | Unbounded module loading | require in a loop over generated names | require is a capability; module count and compile budget | 22 |
Row 14 is the one implementations get wrong, and it is worth restating: if pcall catches
ErrorKind::Limit, every other CPU control on this list becomes bypassable in one line.
5–7. Alternatives, decision, tradeoffs
For each threat there is a cheaper wrong control and a more expensive stronger one. Two worth naming:
| Threat | Cheaper (rejected) | Ours | Stronger (out of scope) |
|---|---|---|---|
| Infinite loop | wall-clock deadline | instruction budget | preemptive scheduling |
| Memory | ulimit on the process | per-engine byte budget | separate address space |
The wall-clock rejection is the same decision made three times now — the recursion limit, the
execution budget, and here. When one criterion (determinism) keeps deciding, it belongs in
docs/architecture.md as a stated principle. It is.
8. Production concerns
- Every control needs a test that proves it fires, and the test must assert
ErrorKind::Limitrather than "an error happened". Lab 23 is fifteen such tests. - Limits must be observable. A host that cannot see "this policy used 82% of its budget" cannot
tune it.
stats()reports usage, not just violations. - Defaults must be safe, and safe defaults are strict. The default budget should stop a runaway in milliseconds, and hosts raise it deliberately. A generous default is a control nobody enabled.
9. References
- The list above is derived from the phase-21 threat list in the curriculum's own brief plus what
Sections 3–5 turned up. Compare it with
rhai's "safety" documentation andmlua's discussion ofLua::set_memory_limit/ hooks. - CVE-2011-4815 and the 2011 hash-DoS cluster, for row 10.
- The Lua sandbox-escape folklore on
lua-users, for row 8.
Concept 3: The Non-Goals
1–3. Concept, problem, mental model
A threat you do not defend against must be written down. An omitted threat reads as a handled one, and a host will assume you handled it.
4. The list
| Not defended | Why not | What to do instead |
|---|---|---|
| Host function cost | The budget charges one unit for a native call. A host function that sleeps, blocks on I/O, or burns CPU is invisible to it | Budget your own functions; run evaluation on a pool with its own timeout |
| Side channels | No constant-time anything. A script can time its own operations and infer host behavior | Do not put secrets where a partially-trusted script can observe timing |
| Memory isolation between engines | Two engines share an address space. A runtime bug in one is a bug for all | One process per trust domain, if the domains are hostile |
| Fair scheduling | Ember is single-threaded per engine and does not preempt. One long evaluation blocks its thread | A pool, a timeout, and a supervisor |
| Runtime exploits | Ember is memory-safe Rust with no unsafe in the default build — which makes memory-corruption bugs unlikely, not impossible, and says nothing about logic bugs | Process isolation for hostile input; keep the crate updated |
Drop order at shutdown | Userdata Drop impls run as the engine tears down and cannot re-enter it | Do not put re-entrant logic in Drop |
| Denial of service via the host's own callbacks | See row 1 | Same |
| Bytecode provenance | The validator proves a chunk is well-formed, not that it came from a trusted source | Sign or authenticate cached chunks yourself |
| Script authorship / supply chain | Ember runs what you give it. Where the policy came from is your problem | Review, sign, and version your policies |
Row 1 and row 5 are the two that matter most, and both are worth saying out loud in the README:
- The instruction budget bounds the script, not the host functions the script calls. A sandbox cannot see inside code it did not compile.
- "Written in safe Rust" is a strong statement about one bug class and no statement at all about the others.
5–8. Alternatives, decision, production concerns
Decision: write all of the above in
docs/sandboxing.mdunder a heading that says "Non-goals", and link it from the README.
The alternative — quietly omitting them — is what most projects do, and it is why "sandboxed" is a word that has stopped meaning anything. The list of non-goals is the most credible part of a security document, because it is the part nobody writes unless they actually did the analysis.
Production concerns:
- Non-goals change when the code changes. Adding a
sendfeature adds threading concerns. Adding filesystem modules adds path traversal. Re-read this list whenever the capability surface changes — and the golden capability-surface test is what makes "the capability surface changed" a visible event. - Do not let the non-goals become an excuse. "Host function cost is a non-goal" does not mean "we do not care"; it means the control lives in the host, and the docs must say what the host should do.
9. References
- The Firecracker threat model document (
firecracker/docs/design.mdand its security section) — read it for structure and for how confidently it states its boundary. It is an unusually good example of naming what is not protected. - gVisor's security model, for the same reason.
- The
rustsecadvisory database andcargo audit/cargo deny, which Section 6 wires into CI.
The Document
docs/sandboxing.md is written in Lab 23 and has exactly this shape:
# Ember: Sandboxing and Threat Model
## Production profile (the paragraph from Concept 1, verbatim)
## Assets (what an attacker would want: host data, CPU, memory, availability)
## Adversary ("partially trusted script author", spelled out)
## Controls (the 15-row table, each with a link to its test)
## Non-goals (the 9-row table — the part that makes the rest credible)
## Escalation path (what to do if you need more: process, WASM, microVM)
## Reporting (how to report a vulnerability, and what qualifies as one)
That last section is not decoration. "What qualifies as a vulnerability" is defined by the profile above: a script escaping the capability set is a vulnerability; a host function that blocks forever is not. Saying so in advance saves everyone a difficult conversation later.
Things to Notice
- A security claim without a threat model is a mood. The profile paragraph is the claim.
- The non-goals list is the most credible part of the document, because it is the part that only gets written if the analysis actually happened.
pcallcatching limit errors would bypass every CPU control at once. One line.- Determinism has now decided four design questions — recursion limits, the execution budget, table iteration, and the wall-clock rejection here. That is a principle, and it is written down.
- "Written in safe Rust" is a statement about one bug class. It is worth saying and it is not a security model.
- A sandbox cannot see inside host functions. The most common overclaim in embedded runtimes.
- The capability-surface golden test is what makes "the threat model changed" visible.
Validation / Self-check
- State Ember's production profile. Which sentence in it is doing the load-bearing work?
- Why is "is Ember secure?" unanswerable, and what is the answerable version?
- Give five threats from the table with their controls and the lab that implements each.
- Why must
pcallnot catch limit errors? What does one line of script otherwise achieve? - Name four non-goals and, for each, what the host should do instead.
- Why is a wall-clock deadline rejected as a primary control, and which other three decisions used the same criterion?
- What does the validator prove about a cached chunk, and what does it not prove?
- Why is the non-goals list the most credible section of a security document?
- Which test makes a change to the threat model visible in code review?
Next: Lab 19 — The Engine API.