Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Threat model

This page states, deliberately, what apimock’s actual security surface is in 6.0.0 — what each actor can do, what apimock allows on purpose and why, and what apimock is not trying to protect against. It supersedes RFC 048 § 9, which was written before apimock set existed and never revisited once it shipped — the gap this page exists to close, by living somewhere it will actually be read again.

Non-goals — read this first

apimock is a development tool. It is not hardened against hostile input, and it is not designed for multi-tenant use. It should not be exposed to an untrusted network. Nothing below changes that. If you need a mock server a stranger can safely send traffic to, this isn’t it — bind it to localhost, run it behind something that is designed for that job, or don’t expose it at all.

apimock also does not defend a user against their own commands. If you type apimock set --rule-set /etc/whatever.toml --allow-outside, it does what you asked — the same way cp or rm would.

Actors

  • A person at a shell. Trusted, along with the filesystem they own. Explores by trial, reads output, adjusts. apimock does not second-guess a command this actor typed themselves.
  • An AI CLI agent. This is the actor 6.0.0’s CLI surface is designed for, and the one that changes the picture from earlier releases: it composes commands from material it did not author — a spec being mocked, a filename in a task description, a path another tool handed back — runs them non-interactively, and builds on the result without a human reviewing any single step. “The user asked for it” does not hold the same way here, because the user did not type the command; an agent acting on untrusted input can be induced to run one it shouldn’t.
  • CI. Runs a fixed set of commands, asserts exit codes, never answers a prompt, has no hidden network dependency.
  • The GUI application. A long-lived session against the apimock-config/apimock-routing library API directly — not through the CLI. Its trust model is “the library API keeps working,” not anything CLI-specific.
  • An MCP host. Behaves and fails the same way the AI CLI agent does, through an adapter. No separate model is needed for it.

Surface

What the server reads. The root config (apimock.toml), every file listed in service.rule_sets, every file listed in service.middlewares (.rhai scripts, compiled once at startup — no file-watch or hot-reload), and TLS certificate/key files when configured. All are read from paths an operator put in their own config file. A file actually served in response to a request — whether found via the dyn-route fallback, a rule’s respond.file_path, or a path a Rhai middleware returns — is confined to the directory it was resolved against; see T3, below.

What the CLI writes. apimock set creates or rewrites the root config and a rule-set file, in place, preserving comments and key order (RFC 056). --init writes a starter config (and optionally a rule-set file, a middleware file, a TLS section) non-interactively or interactively. Nothing else in the CLI writes to the filesystem.

What middleware can do. Rhai’s engine is constructed with Engine::new() — the default, unsandboxed configuration; apimock registers no filesystem, network, or environment-variable access, and Rhai’s standard library doesn’t expose those on its own, so a script’s own code cannot open sockets or read arbitrary files directly. A script receives exactly two values: the request’s url_path and its parsed JSON body — no headers, no method. Its return value drives the response: a string names a file to serve, or a map selects file_path / json / text. A script that fails to compile or panics at runtime is logged and the request falls through to the next stage — it cannot crash the process.

A non-terminating script fails its own request, not the process (RFC 068 S-03). Before RFC 068, this page said a failing script “cannot crash the process, but it can silently degrade routing” — true about crashing, wrong about non-termination: a script evaluated directly on an async worker thread, with no operation limit, simply never returned, which is a stalled server, not degraded routing. [service].middleware_max_operations (default 10,000,000, generous for any reasonable script) bounds a script by work done; evaluation also runs in a spawn_blocking task rather than directly on the async runtime, so a script that still doesn’t terminate costs one slow request, not one permanently lost worker. Fixed call-depth and string/array/map-size ceilings apply regardless of the configured operation limit — there is no legitimate mock-middleware reason to need more of either.

A request body is capped before it is buffered (RFC 068 S-02). [service].max_request_body_bytes (default 32 MiB) bounds how much of one request body is ever collected into memory; a body over the limit gets 413 instead of being buffered first. Before this, a body of any size was collected whole — the external audit measured one 256 MiB request taking the process from 9 MiB RSS to 462 MiB, reachable by a single unauthenticated request with no connection limit to bound concurrency either.

What TLS touches. apimock terminates TLS itself via rustls — this is not a reverse-proxy setup. Certificates can hot-reload without rebinding the listener (RFC 020): in-flight handshakes finish on the old cert, new connections get the new one. There is no client-certificate (mTLS) support; enabling or disabling TLS itself still requires a full restart.

Deliberate allowances, with reasons

apimock set creates a file containing rule-set TOML at a caller-named path — confined by default (RFC 062). The underlying capability is real: set is, in the abstract, a file-creation primitive. For a person at a shell this is unremarkable — the same category of thing cp’s destination argument is. For an AI CLI agent composing an unreviewed path, it stops being unremarkable, because the “caller” who named the path and the user who will be blamed for what happened aren’t reliably the same judgment. set refuses a --rule-set (or any other caller-supplied write target) that resolves outside the root config’s own directory treeusage, exit 2, nothing written, not even a bootstrap file — unless --allow-outside opts back in. Resolution is by canonicalised path where the target exists, and by canonicalised parent where it doesn’t, since set legitimately creates files that don’t exist yet and a naive existence-requiring check would break ordinary bootstrapping. Refusing rather than warning follows the precedent already set for --dry-run (RFC 057 REVIEW-001 § 4): a safety affordance that sometimes acts anyway is worse than one that declines outright, because the exception is invisible at the call site.

This confinement is CLI-layer only. apimock-config’s library API — and so the GUI, once it consumes it directly — does not inherit it. This is deliberate, not an oversight: pushing the check into Workspace would change a published library API to protect against a threat model (an untrusted caller composing paths) that doesn’t describe the GUI’s own actor. If confinement should hold for every caller of the library, that’s a follow-up RFC’s decision, not something bundled quietly into this one.

--file (on get/set) is out of scope for this confinement, on purpose. set --file <path> never reads that path — it stores the string as respond.file_path in the rule-set TOML, for the server to read later, at serve time. It’s a reference, not a write target, so the write-path confinement above doesn’t apply to it the way it applies to --rule-set. (get --body-file <path> is unrelated: it’s a genuine local read, used only to build a synthetic request for apimock get’s own dry-run matching — never anything the server itself touches.)

Verbose logging redacts headers, query strings and body keys alike — RFC 073 S-05. log.verbose.header (default off) prints every request header, with anything matching the credential-shaped denylist (authorization, cookie, set-cookie, proxy-authorization, x-api-key, token, access_token, refresh_token, password, secret, client_secret, api_key, or a configured allowlist/denylist) replaced with a redacted marker — RFC 051. log.verbose.body, independently gated and also default off, used to print the raw query string and the full JSON body with no redaction at all — RFC 051 flagged this itself (its own Unresolved Question 2) and deliberately left it for a later RFC rather than scope-creeping into it. RFC 073 closes that gap: the same denylist/allowlist that already governed headers now governs a query-string parameter’s value (?token=secret?token=[redacted]) and a JSON body’s object keys, recursively ({"password": "hunter2"}{"password": "[redacted]"}, however deeply nested) — one policy, applied wherever a name-value pair can leave the process, not a separate list per surface. This also covers the trace channel’s own capture_body (RFC 023), not only the console: an out-of-process subscriber over the UDS/TCP transport receives the same redacted body a verbose console log would show, not the raw one.

The trace transport is not authenticated (RFC 073). A Unix-domain socket subscriber connects with owner-only (0600) filesystem permissions since RFC 073 — the socket file used to inherit whatever the process umask produced, often readable by any local user. That permission restriction has no Windows equivalent (the UDS transport is Unix-only; Windows always uses TCP) and the TCP transport itself has no login, token, or allowlist of any kind — anything that can open a connection to the configured address receives the live request trace feed. apimock only warns (loudly, at startup) if the configured address isn’t loopback; it does not refuse to bind one, since an operator may have a real reason this process can’t see. Bind the TCP trace transport to loopback, or prefer the Unix-socket transport wherever the platform supports it, the same way the server’s own listener defaults to loopback for the same reason (see the Non-goals section above).

Credentialed CORS reflection is allowed, but only for a named or loopback origin (RFC 067). When a request carries Cookie or Authorization, apimock reflects the request’s Origin into Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials: true — but only if that origin is http://localhost:*, http://127.0.0.1:* (allowed implicitly — a page served from the developer’s own machine is already inside the trust boundary the loopback bind assumes), or named in [service].cors_allow_credentials_origins (exact origin strings, empty by default). Every other credentialed request still gets a response — refusing it outright would break the many requests that carry a Cookie incidentally and need no CORS at all — but with the same safe, non-credentialed Access-Control-Allow-Origin: * a request with no Cookie/Authorization gets; the browser is what then refuses a credentialed cross-origin script access to the response.

Before RFC 067, this was unconditional: any origin got credentialed reflection, no allowlist, not configurable — the textbook CORS misconfiguration, and this page’s own D-04 gap (the audit’s finding that this allowance existed without a stated reason here). Binding to 127.0.0.1 is not a mitigation for the unconditional case: the dangerous request originates from the developer’s own browser, on a page they merely visited, targeting their own loopback listener — the default bind protects against a remote attacker reaching the port, not against this.

Settled decisions, restated in full

T2 — a configuration write becomes code execution — decided 2026-08-17: deferred, not refused. service.middlewares lists Rhai scripts the server compiles and runs; set could, in principle, attach one. It does not: set’s first cut never adds, changes, or removes service.middlewares entries — existing entries pass through untouched.

This was not decided on maintenance cost, though it was first argued that way. Checking the source showed the machinery set would need already exists: Server::new already compiles and propagates middleware failures loudly at startup, middleware paths already resolve against the config directory, and requires_reload already models the “changes take effect on restart” semantics set would need. The maintenance argument was asserted without checking the code, and it was wrong.

The real argument is about a capability, not effort: a caller who can invoke set could cause the server to run a file of their choosing on its next boot. For a person at a terminal that’s unremarkable — they could edit the file directly. For an agent acting on untrusted input, it is the difference between changing what a mock returns and running code in the process — worth a deliberate scope decision rather than an incidental default.

That argument has a hole worth stating honestly: refusing does not prevent it. An agent that can be induced to run apimock set can be induced to write the .rhai file directly instead. The refusal is a speed bump against a capable attacker, not a barrier — its value is against the inadvertent case (a “just set this field” verb quietly gaining code-execution as a side effect), not a determined one.

The real cost of building this later isn’t maintenance either — it’s correctness. A set that writes a middleware path can leave a workspace that no longer boots (a missing file, or one that doesn’t compile) — discovered only when the server next starts, long after set reported success. Building this means pulling Rhai compilation into set’s own path so success is verified before it’s reported, not treating service.middlewares like every other field.

If middleware attachment is built later, it must be through an explicit command or flag — never through a generic “set this field” verb — so code execution is never a side effect of an ordinary config edit, and intent is visible in whatever composed the command.

T1 — path traversal through a caller-supplied write path — status as of 6.0.0: enforced. RFC 048 required this without specifying a mechanism; RFC 062’s confinement (above) is that mechanism, for set’s one caller-supplied write target.

T3 — path traversal through the serve path (the read side) — status as of 6.0.0: enforced. Complementary to T1, and the gap this page itself flagged when it first shipped (RFC 062) — now closed (RFC 063). A resolved file is served only if it stays within the directory it was resolved against, at every site that can produce one: the dyn-route fallback (a request-derived path), a rule’s respond.file_path, and a path a Rhai middleware script returns (both operator-authored). Each checks by canonicalising the resolved candidate and confirming it remains inside the canonicalised base directory for that site — the fallback respond dir, the rule set’s own respond dir, or the middleware script’s own directory, respectively. A violation is a bare 404, indistinguishable from an ordinary not-found, so a prober learns nothing about whether the target exists.

Unlike T1, this has no opt-out. RFC 062 gave set --rule-set an escape hatch because a caller naming an outside path is asking for it and is the only one exposed; the serve path is reachable by anything that can send a request, so no config toggle turns it off. If files genuinely live elsewhere, point respond_dir at them directly — explicit, per rule set, already supported.

Unlike T1, this is not CLI-layer only. It’s enforced inside apimock-server itself — the running server, and apimock get (RFC 055), which calls the exact same dispatch functions the server does, so neither can answer differently than the other for the same request. The asymmetry the previous version of this page flagged — write path confined, read path open — no longer exists.

As defence in depth, normalize_url_path also strips a .. segment from the request path before it reaches file resolution at all — this closes the ordinary case earlier, but it is not the fix: it cannot help respond.file_path or a Rhai-returned path (neither is built from a URL), and a symlink escaping the base is caught only by canonicalise-and-compare. Two independent controls, deliberately: the RFC’s own framing was “neither alone is the fix.”

This was a vulnerability in released versions, not only a v6 hardening. Before the fix, the dyn-route fallback joined a request-derived path onto the response directory and checked only that the result existed, so a request carrying an un-normalised .. segment could read a file outside it.

Affected: 4.0.0 through 5.19.0 — both supported lines, confirmed by exploit at 4.8.0 and on current code. Fixed in 4.8.1, 5.19.1 and 6.0.0, published with GHSA-72g6-wgrg-vhm7.

Whichever line you are on, the patch release for that line is the fix — there is no requirement to change major version. On 4.x the npm release carries the v4x dist-tag (npm install apimock-rs@v4x), since latest tracks the newest line.

Practical exposure was bounded: apimock binds 127.0.0.1 by default, so it was not reachable off-host unless the listener had been pointed elsewhere, and it required a client that does not normalise .. before sending (browsers and most proxies and HTTP libraries do). Bounded is not the same as absent, which is why it was fixed rather than documented.

Per-request cost: the base directory is canonicalised once, when the server starts (or apimock get runs) — not per request. The only per-request work is canonicalising the resolved candidate, measured at under a microsecond on a warm filesystem cache, immaterial next to the network I/O already in every request.

Other threats RFC 048 named, current status:

  • Indirect prompt injection reaching set through an AI agent isn’t solvable inside the CLI — apimock’s obligation is not to amplify it: no shell evaluation of arguments, no implicit writes, and destructive operations stay explicit rather than inferred. Unchanged by this RFC.
  • Secret leakage through verbose output — see the redaction section above; addressed (RFC 073 extended header redaction to query strings and body keys). Not a claim that every possible secret shape is caught — the denylist is name-based, so a value under an unanticipated key still prints, the same fail-open trade-off RFC 040 already made for headers.
  • Symlink / TOCTOU on a configuration writeset’s atomic write-then-rename (RFC 056) and external-change detection (RFC 024, 042) cover the write side; nothing here re-litigates that.
  • A server-hosted configuration API — never built. Not a live surface.
  • Supply chain of new dependencies — covered by the existing cargo audit / lockfile CI gates (RFC 033), no new mechanism needed per dependency.