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

ProfileWhat it would requireWhy not
A. Trusted scripts onlynothing; no limits at allToo weak: an accidental infinite loop takes down a service. Limits are cheap
B. Partially trusted (ours)limits, capabilities, determinism, no panicsAchievable in-process, and it matches the actual use case
C. Hostile multi-tenant, in-processmemory isolation, side-channel mitigation, fair scheduling, a formally-reviewed runtimeNot achievable in-process. Claiming it would be a lie
D. Hostile, out-of-processa process/VM boundary around the whole engineAchievable — 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 gainWe lose
A claim we can defend line by lineThe word "sandbox" without qualification
Cheap, deterministic controls that stop real accidentsNothing 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 require from 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

#ThreatExampleControlLab
1Infinite loopwhile true do endInstruction budget, in the fetch position11, 23
2Infinite recursionlocal function f() return f() endCall-depth limit, counted across native boundaries7, 11, 23
3Memory exhaustion (tables)for i=1,1e9 do t[i]=i endMemory budget checked in Heap::alloc15, 23
4Memory exhaustion (strings)("x"):rep(1e9)Budget checked before allocating, in rep/concat/format16, 21
5Result-list amplificationf(table.unpack(huge))Max-results and max-arguments limits17, 23
6Compile-time bombs100k-deep nesting; 10 MB of sourceParser depth limit; source-size limit at the boundary2, 23
7Error amplificationan error message echoing a 10 MB stringAll messages truncate script-controlled data24
8Escaping the capability setos.executeNothing is registered unless granted; enumerated-surface golden test21
9Cross-evaluation state leaka script writes a global read by the next requestFresh globals table per evaluation23
10Hash-collision DoScrafted table keysPer-engine seeded hash; iteration order independent of it16
11Nondeterminismos.time, math.random, hash-order iterationExcluded from SAFE; insertion-ordered tables13, 21
12Panic in the runtimeany malformed input reaching an unwrapNo-panic policy; fuzzing; catch_unwind at the host callback boundary26
13Malformed bytecodea corrupted or hostile cached chunkThe validator, run before execution9, 22
14pcall swallowing a limitwhile true do pcall(...) endLimit errors are not catchable by pcall21
15Unbounded module loadingrequire in a loop over generated namesrequire is a capability; module count and compile budget22

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:

ThreatCheaper (rejected)OursStronger (out of scope)
Infinite loopwall-clock deadlineinstruction budgetpreemptive scheduling
Memoryulimit on the processper-engine byte budgetseparate 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::Limit rather 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 and mlua's discussion of Lua::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 defendedWhy notWhat to do instead
Host function costThe budget charges one unit for a native call. A host function that sleeps, blocks on I/O, or burns CPU is invisible to itBudget your own functions; run evaluation on a pool with its own timeout
Side channelsNo constant-time anything. A script can time its own operations and infer host behaviorDo not put secrets where a partially-trusted script can observe timing
Memory isolation between enginesTwo engines share an address space. A runtime bug in one is a bug for allOne process per trust domain, if the domains are hostile
Fair schedulingEmber is single-threaded per engine and does not preempt. One long evaluation blocks its threadA pool, a timeout, and a supervisor
Runtime exploitsEmber is memory-safe Rust with no unsafe in the default build — which makes memory-corruption bugs unlikely, not impossible, and says nothing about logic bugsProcess isolation for hostile input; keep the crate updated
Drop order at shutdownUserdata Drop impls run as the engine tears down and cannot re-enter itDo not put re-entrant logic in Drop
Denial of service via the host's own callbacksSee row 1Same
Bytecode provenanceThe validator proves a chunk is well-formed, not that it came from a trusted sourceSign or authenticate cached chunks yourself
Script authorship / supply chainEmber runs what you give it. Where the policy came from is your problemReview, 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.md under 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 send feature 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.md and 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 rustsec advisory database and cargo 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.
  • pcall catching 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

  1. State Ember's production profile. Which sentence in it is doing the load-bearing work?
  2. Why is "is Ember secure?" unanswerable, and what is the answerable version?
  3. Give five threats from the table with their controls and the lab that implements each.
  4. Why must pcall not catch limit errors? What does one line of script otherwise achieve?
  5. Name four non-goals and, for each, what the host should do instead.
  6. Why is a wall-clock deadline rejected as a primary control, and which other three decisions used the same criterion?
  7. What does the validator prove about a cached chunk, and what does it not prove?
  8. Why is the non-goals list the most credible section of a security document?
  9. Which test makes a change to the threat model visible in code review?

Next: Lab 19 — The Engine API.