# Plugin Runtime Threat Model **Phase:** Phase 06 — Plugin System (#21 WASM Sandbox, #22 Hooks/Lifecycle, #23 Audit/Docs) **Author:** Claude Opus 4.7 (1M context) **Last revised:** 2026-05-19 **Status:** **Internal draft — feeds the external audit-package (B-006).** **Scope:** Everything a third-party WASM plugin can touch — from manifest parsing through the worker invocation path, the audit pipeline, the lifecycle DB rows, and the tenant-admin UI surface. This document is the operator-facing input to the external Cure53 / SySS / secfault audit. It enumerates the assets the runtime protects, applies STRIDE per asset, and maps each mitigation to a concrete `file:line` in the codebase. Every claim a mitigation makes must be verifiable by reading the linked source. If you find a mitigation cited here that no longer exists, file the gap in `docs/security/plugin-internal-findings.md` and patch the model in the same commit. The model intentionally treats the **plugin code as adversarial** — a malicious plugin author, a compromised supply chain, or a buggy plugin that happens to do the wrong thing under load are all in scope. Threats from outside the boilerplate (compromised CI worker, stolen DB credentials, malicious host operator) are _out of scope_ and handled by `docs/security/threat-model.md` (the broader platform model). --- ## 1. Asset catalogue | ID | Asset | Where it lives | Why it matters | | ---- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | A-01 | Plugin **WASM binary** | Filesystem on `worker-plugin` pod, loaded via `loadBinary` seam in `apps/worker/src/jobs/plugin-invoke.ts:83` | Untrusted code that executes in-process. Sandbox-escape = host RCE. | | A-02 | Plugin **manifest** (`Plugin.manifest` JSON) | `prisma/schema/plugins.prisma:19` (`Plugin.manifest`) | Declares which capabilities the plugin is allowed to request. Tampering = privilege escalation. | | A-03 | **Ed25519 trusted-key allowlist** | Env var `PLUGIN_TRUSTED_PUBLIC_KEYS`, parsed at `packages/plugin-runtime/src/signature.ts:95` (`parseTrustedPublicKeys`) | Trust root for every binary. Compromise = arbitrary code execution under any plugin id. | | A-04 | **Capability gateway** invocation state | `packages/plugin-runtime/src/capability-gateway.ts:78` (`createCapabilityGateway`) | The single chokepoint between WASM and host I/O. Bypass = arbitrary host calls. | | A-05 | **PluginCapabilityUse** audit rows | `prisma/schema/plugins.prisma:77` (model `PluginCapabilityUse`) | Tamper-evidence for "what did this plugin do?". Loss = no forensics; manipulation = false alibi. | | A-06 | Plugin-owned **SQL migrations** | `applyPluginMigration` in `packages/plugin-runtime/src/migrations.ts:200` | DDL run as the host DB role. Bypass = cross-tenant DROP/GRANT. | | A-07 | **Plugin DB rows** (`Plugin`, `PluginUserConfig`) | `prisma/schema/plugins.prisma:14`, `:48` | Lifecycle state. Tampering = silent auto-enable / wrong key/sha pinning. | | A-08 | **Tenant install API** | `apps/web/app/routes/api.tenants.$tenant.plugins.tsx:43` (`action`) | Entry-point a tenant admin uses to install plugins. CSRF / permission failure = takeover. | | A-09 | **Hook outbox events** (`plugin.hook.*`) | `packages/plugin-runtime/src/hooks/emit.ts:91` (`emitPluginHook`) | Carrier between host business writes and plugin invocations. Cross-tenant fan-out is the catastrophic failure. | | A-10 | **Crash tracker counters** | `packages/plugin-runtime/src/crash-tracker.ts:90` (`createCrashTracker`) + SharedStore keys `plugin:crash::` | Wrong increment = unwarranted auto-disable (availability) or missed disable (uptime + cost). | | A-11 | **Sandbox memory** (WebAssembly.Memory cap) | `packages/plugin-runtime/src/runtime.ts:117` | A plugin that grows past the cap can OOM the worker. | | A-12 | **Plugin output buffer** | `apps/worker/src/jobs/plugin-invoke.ts:248` (sanitization site) | Plugin-controlled bytes flowing into audit/log/Sentry. Risk: PII exfiltration into operator-visible surfaces. | | A-13 | **Rate-limit counters** | Deps seam `consumeRateLimit` in `apps/worker/src/jobs/plugin-invoke.ts:91` | Without enforcement, a single plugin can starve all other tenants. | | A-14 | **Plugin worker emergency stop** | Dedicated `worker-plugin` service (`WORKER_QUEUE_GROUP=plugin`) plus queue pause/drain and lifecycle disable controls | Last line of defence. Failure to execute = no emergency-stop. | --- ## 2. STRIDE matrix per asset Each row is `Asset → Threat → Concrete attack scenario → Code-mapped mitigations`. Mitigation links use `file:line` for the primary anchor. **Every linked line must exist**; if a refactor moves it, update this table in the same commit. The CI hook in `.gitea/workflows/plugin-threat-model-check.yml` (to be added by the security-findings cleanup) will grep for stale anchors. ### A-01 — Plugin WASM binary | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Spoofing** | Attacker uploads a binary that pretends to come from a trusted publisher. | (1) Ed25519 verify at invoke time: `apps/worker/src/jobs/plugin-invoke.ts:193` calls `verifyPluginSignature` from `packages/plugin-runtime/src/signature.ts:48`. (2) Trusted-key allowlist enforced at `signature.ts:59-61`. (3) SHA-256 of binary must match `manifest.wasmSha256` (`signature.ts:53-56`). | A leaked publisher key. Mitigation: rotate via env update + redeploy worker. Documented in `docs/architecture/plugin-system.md`. | | **Tampering** | Binary on disk is replaced after install. | SHA-256 is recomputed on **every** invocation (`signature.ts:53`); a tampered binary fails `binary_hash_mismatch` and the plugin-invoke job returns `signature_invalid` (`plugin-invoke.ts:198-205`). | None significant — verification is per-invocation, not per-install. | | **Repudiation** | Operator/tenant denies running the binary. | Per-invocation audit row is fail-closed in `plugin-invoke.ts`: audit write failure logs `plugin.invoke.audit_failed` and throws so the queue retries/dead-letters. Per-capability-use rows go through `PluginCapabilityUse` outbox; the production plugin worker enables recorder fail-closed mode. | Local/dev callers can still use best-effort capability telemetry when they intentionally leave `failClosed` disabled. | | **Info disclosure** | Binary embeds sensitive identifiers (publisher secret accidentally bundled). | Out of scope for the runtime. Publisher CI lint can catch (`packages/cli/src/commands/plugins/lint.ts`). | Publisher-side hygiene; not enforceable here. | | **DoS** | Binary contains an infinite loop or memory-bomb. | (1) Plugin invocation runs in a worker-thread sandbox and `PLUGIN_MAX_WALL_CLOCK_MS` terminates non-yielding workers (`apps/worker/src/jobs/plugin-sandbox-isolated.ts`). (2) Fuel counter is charged per host-fn call (`runtime.ts:170`, gateway `capability-gateway.ts:120`). (3) Memory cap enforced by `WebAssembly.Memory({ maximum })` at `runtime.ts:117-118`. (4) Trap detection branches to `memory_limit_exceeded` / `trap` outcomes (`runtime.ts:176-183`). (5) Per-tenant sliding-window rate limit `consumeRateLimit` (`plugin-invoke.ts:208-212`). | **Per-instruction fuel is still not available in V8.** Pure-WASM loops are bounded by coarse wall-clock termination, not instruction accounting; Wasmtime remains the richer audit target. | | **Elevation** | Binary uses host functions it did not declare. | Capability gateway rejects undeclared invocations: `capability-gateway.ts:96-105` (`manifestDeclaresCapability` check) throws `CapabilityDeniedError`. | None — closed enum + manifest-declared subset is necessary AND sufficient. | ### A-02 — Plugin manifest | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Spoofing** | Manifest claims `signedBy` of a trusted publisher but the binary is unsigned. | Manifest's `signedBy` is compared against the trusted-key allowlist before signature verification runs (`signature.ts:58-61`). Verification uses the manifest-declared key, then confirms the key is trusted. | None — trust check + verify are atomic in `verifyPluginSignature`. | | **Tampering** | Manifest is mutated post-install to silently grant new capabilities. | (1) Manifest is canonicalised + included in the signed payload (`signature.ts:54-56` for binary hash; signature scope per `docs/architecture/plugin-system.md`). (2) Install-time F-005 checks reject untrusted publisher keys and run `verifyPluginSignature` against the pre-positioned binary before creating the `Plugin` row. (3) Stored manifest is loaded fresh at invocation (`plugin-invoke.ts:173-181`) — no caching means a mutation requires a row update. App-level plugin lifecycle mutations are gated by `app.plugins.manage`; direct row mutation requires DB access. | None for the signed-binary/install path; direct DB writes remain an operator/DB-access boundary. | | **Repudiation** | Tenant claims they never approved capability X. | Lifecycle service writes an audit event on install (`PluginLifecycleService.ts:81-100` — see `installPlugin`). The audit-events store is hash-chained. Tenant settings requires explicit list-level capability review plus a server-generated manifest digest before writing missing grants. Grant audit metadata records plugin version, review digest, approved capability names, approved scope JSON and the matching manifest scope snapshot. The plugin `audit` host binding itself emits structured worker log/capability telemetry, not a durable audit-events DB row. | None for the install/grant events themselves. Residual UX work remains around a fuller guided tenant-admin consent dialog — F-001. | | **Info disclosure** | Manifest contains sensitive metadata. | Description field max 2048 chars (`manifest.ts:61`), strict Zod schema (`.strict()` at `manifest.ts:38, 46, 53, 75`). | None — manifest is operator-visible by design. | | **DoS** | Manifest declares thousands of capabilities or impossibly long strings. | Closed-enum `PLUGIN_CAPABILITY_NAMES` (`capabilities.ts:13-23`) caps the capability vocabulary at 9 entries. String fields have explicit max lengths in `manifest.ts:60-67`. | None significant. | | **Elevation** | Manifest invents a new capability name. | `z.enum(PLUGIN_CAPABILITY_NAMES)` (`manifest.ts:43`) rejects anything not in the closed enum. `parsePluginManifest` returns issues; `installPlugin` throws `manifest_invalid` at `PluginLifecycleService.ts:74`. | None — the enum is the trust boundary. | ### A-03 — Ed25519 trusted-key allowlist | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **Spoofing** | Adversary tries to add a key to the allowlist via runtime path. | Allowlist is **read from env only** at process start (`signature.ts:95-101`); no runtime mutation path exists. Adding requires a pod restart. | Operator must restrict who can edit the worker env (Coolify role). Out of runtime scope. | | **Tampering** | Env value modified mid-run. | `parseTrustedPublicKeys` returns a fresh `ReadonlyArray` per call. Each worker invocation snapshots `trustedPublicKeysHex` (`plugin-invoke.ts:109`) — mutation requires worker restart. | None. | | **Repudiation** | Operator denies adding a key. | Coolify env-change history. Not the runtime's job. | Out of scope. | | **Info disclosure** | Public keys leak. | Public keys are **public** by definition. No risk. | None. | | **DoS** | Allowlist set to empty / invalid CSV → no plugins can run. | Fail-closed is intentional: invalid hex entries are filtered at `signature.ts:99-100`; empty list → `verifyPluginSignature` returns `public_key_not_trusted` (`signature.ts:59`); invocation returns `signature_invalid`. Operator must intentionally seed the env. | None — fail-closed by design. | | **Elevation** | Wrong key added (typo) effectively trusts a malicious publisher. | Operator process: keys are added via PR review per the documented runbook in `docs/architecture/plugin-system.md`. | Operator-side; mitigated by review. | ### A-04 — Capability gateway | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | **Spoofing** | Plugin calls a host-fn under a capability it doesn't have. | `manifestDeclaresCapability` check on every invoke (`capability-gateway.ts:96`); throws `CapabilityDeniedError` (`:104`). | None — deny by default. | | **Tampering** | Plugin tries to mutate the `hostFns` map after gateway creation. | Map is captured by closure at `capability-gateway.ts:79`; no exposed mutator. | None. | | **Repudiation** | Denied call leaves no audit trace. | `onCall` notifier fires for `denied` outcome (`capability-gateway.ts:98-103`); the recorder buffers it for the batch audit-write (`audit.ts:108-121`). | Audit sink can be down — see A-05 residual. | | **Info disclosure** | Error message leaks host internals. | `CapabilityDeniedError.message` is `"capability denied: ()"` only (`capability-gateway.ts:56`). No stack/host internals exposed to the plugin. | None. | | **DoS** | Plugin spams capability calls to drain CPU. | Fuel charged per call (`:120`); `fuelCharged()` records host-fn fuel for audit/limits planning. Default cost 100 fuel/call (`:71`). Invocation wall-clock is bounded by `PLUGIN_MAX_WALL_CLOCK_MS`. | Fuel cap is host-fn-based, not per-instruction. F-002 remains the Wasmtime upgrade track. | | **Elevation** | `notifyCall` throws and tears down the host before the capability result is returned. | Try/catch isolates the synchronous notifier (`capability-gateway.ts:85-90`), then production flush handles durable-sink failure at sandbox close with `failClosed: true`. | The immediate host-fn result is not interrupted by notifier bugs; production still fails closed before acking the invocation. | ### A-05 — PluginCapabilityUse audit rows | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Spoofing** | Attacker writes rows with another tenant's id. | Recorder is invocation-scoped — `tenantId` set once at construction (`audit.ts:114-119`) and copied into every record. Insertion goes through `PluginCapabilityUseStore.insertMany` (`apps/worker/src/jobs/plugin-capability-use.ts:39`), itself called only from the worker handler. | None at the runtime layer. The outbox queue must remain operator-only writeable. | | **Tampering** | Rows modified after-write to hide an exfiltration. | Postgres-level: only the schema-owner role can `UPDATE` directly. App-level: there is no `UPDATE PluginCapabilityUse` code path. Rows are hash-chained per tenant through `prevHash` / `rowHash`, and operators verify integrity with `toolctrl-cli plugins verify-audit-chain `. | Keep the verifier in the production runbook; a web endpoint remains deferred. | | **Repudiation** | Operator/tenant denies an action. | Two-tier evidence: per-invocation row (`Plugin.installedAt` + audit-events table for install) **and** per-capability-use row. | None. | | **Info disclosure** | Body content stored in audit → DSGVO violation. | **Bodies are NEVER stored.** `payloadHash` is SHA-256 of an arg-shape descriptor only (`audit.ts:173-175`). No URL bodies, no DB row payloads. | None when used as designed. Plugin authors writing custom `descriptor` strings that contain PII are a separate problem — `hashCapabilityPayload` should be the only path. | | **DoS** | Plugin spams the recorder until heap exhausts. | `maxEntries` cap (default 1024) per invocation (`audit.ts:91, 109`); excess entries are dropped with a `warn` log. | None. | | **Elevation** | A failed audit write surfaces as a plugin error. | Recorder callers choose the failure mode. The production plugin worker passes `failClosed: true`, so sink failures throw during sandbox finalization and the invocation is recorded as failed instead of silently succeeding. | Local/dev callers can intentionally leave best-effort telemetry enabled; production bootstrap must not. | ### A-06 — Plugin-owned SQL migrations | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Spoofing** | Plugin migration claims to create `plugin_acme_*` but actually creates `users`. | Regex extraction of the table name + prefix check (`migrations.ts:99-104`). Reject = `prefix_violation`. | The regex requires the simple `CREATE TABLE name ( ... )` shape; nested CTEs / `CREATE TABLE LIKE` are rejected outright by the splitter at `migrations.ts:174-180`. | | **Tampering** | Migration uses `pg_*` system functions to mutate operator data. | `FORBIDDEN_TOKENS` list at `migrations.ts:69-81` catches `GRANT`, `REVOKE`, `CREATE USER`, `ALTER USER`, `DROP USER`, `DROP DATABASE`, `DROP SCHEMA`, `COPY`, `TRUNCATE`, `SET ROLE`, `PG_`. Comments stripped before scan (`migrations.ts:61-67`). | Token-list approach; a clever encoding could in principle smuggle. **F-006** — additionally compare via `pg_query_emscripten` AST for defence in depth (deferred). | | **Repudiation** | Plugin author claims they didn't intend the bad migration. | Migration SQL is part of the manifest (in #22 follow-up — stored alongside Plugin row JSON). Migration apply is audited via `applyPluginMigration` logger (`migrations.ts:218-222`). **Gap:** no per-statement audit row. | F-007 — per-statement audit write before execution. | | **Info disclosure** | Migration `SELECT`s from another tenant's table. | Only `CREATE TABLE` and `CREATE INDEX` are allowed (`migrations.ts:171-181`). `SELECT` is rejected as `forbidden_statement`. | None. | | **DoS** | Migration creates 1000 tables. | Statement count is not capped — a manifest can list arbitrarily many statements. **F-008** — add `MAX_STATEMENTS_PER_MIGRATION` cap (default 32). | Currently bounded by manifest size limits only. | | **Elevation** | Migration creates a table without `tenantId`, then plugin reads all rows. | Body must contain `\btenantId\b` (`migrations.ts:106-110`); rejection is `missing_tenant_id_column`. | Regex matches in any context (comment, default value). Acceptable: false positives are operator-visible failures; false negatives are blocked because the runtime never disables this check. | ### A-07 — Plugin DB rows (Plugin / PluginUserConfig) | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------- | | **Spoofing** | Tenant A's row claims to belong to tenant B. | `@@unique([tenantId, pluginId])` at `plugins.prisma:31`; install path captures `tenantId` from URL through `getTenantIdFromUrl` (`api.tenants.$tenant.plugins.tsx:37`). | None at the row level. | | **Tampering** | Tenant admin disables auto-disable by editing the row. | Direct row mutation requires DB access. App-level mutators go through `PluginLifecycleService.enable/disable` which audit. No `update enabled` endpoint exists outside `op=enable | disable` (`api.tenants.$tenant.plugins.tsx:54-61`). | None at the app layer. | | **Repudiation** | Who installed the plugin? | `installPlugin` writes an audit event with `actor` (`PluginLifecycleService.ts:81-100`). | None. | | **Info disclosure** | Listing plugins across tenants. | `listPlugins` scoped by `tenantId` (`PluginLifecycleService.ts:listPlugins`). Loader path verifies `app.plugins.use` for the URL's tenant before returning rows (`api.tenants.$tenant.plugins.tsx:65-69`). | None. | | **DoS** | Tenant installs 10k plugins → DB bloat + worker thrashing. | `@@unique([tenantId, pluginId])` prevents duplicate-id installs. F-009 is closed: `installPlugin` enforces `PLUGIN_MAX_INSTALLS_PER_TENANT` with default cap 50 and returns `tenant_install_limit_exceeded` as HTTP 429. | None at the default cap; operators can set a non-positive override to disable the cap in controlled environments. | | **Elevation** | Admin of tenant A enables a plugin owned by tenant B. | `enable/disablePlugin` queries by `pluginRowId` AND `tenantId` (`PluginLifecycleService.ts:142+`). | None. | ### A-08 — Tenant install API | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | **Spoofing** | CSRF: tricked admin POSTs install on their own tenant. | `validateCSRFToken(request)` (`api.tenants.$tenant.plugins.tsx:46`). JSON clients use the `x-csrf-token` header path so the body remains available for `request.json()`. | F-010 is closed; keep the header-token contract for JSON install clients. | | **Tampering** | Modified body bypasses validation. | Body parsed as JSON; required fields explicitly checked before install. The lifecycle service then enforces the production runtime gate, Zod manifest validation, inclusive host-version range, trusted-key match, disk binary verification, submitted SHA match, duplicate `pluginId` rejection, and tenant install cap. | None for declared fields. | | **Repudiation** | Admin denies install. | `resolveActor` captures user id + IP into the audit (`api.tenants.$tenant.plugins.tsx:88-100`). | None. | | **Info disclosure** | Error message leaks internal state. | `PluginLifecycleError` codes are enumerated (`PluginLifecycleService.ts:35-44`); messages are operator-controlled. | None significant. | | **DoS** | Repeated installs DOS the DB. | Permission gate `app.plugins.manage` + tenant scoping limit blast radius. F-011 is closed: the install route uses a SharedStore sliding-window limiter keyed by `tenant:` with default `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5`; over limit returns 429 + `Retry-After: 60`. | Production fails closed on limiter infrastructure errors by default; fail-open is allowed only when explicitly configured with `PLUGIN_RATE_LIMIT_FAIL_OPEN=true`. | | **Elevation** | Non-admin user calls install. | `verifyUserHasPermission(request, "app.plugins.manage", tenantId)` (`api.tenants.$tenant.plugins.tsx:72-76`). | None. | ### A-09 — Hook outbox events | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **Spoofing** | Producer writes an event for a tenant it doesn't own. | `emitPluginHook` is called only from `.server.ts` host code paths that already have `tenantId` from a permission-gated loader/action. Producer trust = host code trust. | Out of scope; trust is at the host-code call site. | | **Tampering** | Outbox row mutated before dispatch. | Outbox dispatcher is operator-only; no app-level mutate path. Payload is Zod-validated before publish (`emit.ts:98-101`). | None. | | **Repudiation** | "We didn't fire that hook." | Outbox row + per-invocation audit at the worker side give two independent timelines. | None. | | **Info disclosure** | Hook payload over-shares (e.g. user PII). | Each hook has a strict Zod schema with `.strict()` (`registry.ts:36, 46, 57`). Adding a field requires editing the registry. | None at runtime; **schema design discipline** at the host-team level. | | **DoS** | Hook fan-out explodes (one host write → N plugins → N×M jobs). | Single outbox row per hook write (foundation deviation #3 in `hooks-lifecycle.md`); F-012 is closed: `plugin.hook.*` outbox events route to `outbox.plugin-hook`, worker-side fan-out filters manifest-declared subscribers by `tenantId`, and plugin invocation remains bounded by `consumeRateLimit` per plugin. | Residual accepted: a tenant with many enabled subscribers can still create bursty plugin-invoke traffic within configured per-plugin limits. | | **Elevation** | Plugin in tenant A receives an event for tenant B. | Each `plugin.invoke` job carries `tenantId` from the source event (`plugin-invoke.ts:48`); worker handler scopes `loadPlugin` by `tenantId` (`:173-181`). Plugin only runs for its own tenant rows. | None. | ### A-10 — Crash tracker counters | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | **Spoofing** | Attacker increments another tenant's counter to force their auto-disable. | Counter key is `plugin:crash::` (`crash-tracker.ts:82`). Only `recordFailure` writes; only invoked from `plugin-invoke.ts:157-161` with `payload.tenantId` from the job (not user input on the request path). | Outbox payload trust — see A-09 elevation. | | **Tampering** | Direct SharedStore writes manipulate the counter. | SharedStore credentials are operator-only. | Out of scope. | | **Repudiation** | Why was my plugin auto-disabled? | `recordFailure` returns `{ failureCount, autoDisabled }`; logger emits `plugin.crash.auto_disabled` with full context (`crash-tracker.ts:128-133`). Auto-disable also writes a notification (`notify` seam). | None significant. | | **Info disclosure** | Counter exposes invocation rate. | Counter is per `(plugin, tenant)` — knowing the counter requires SharedStore access. | None. | | **DoS** | Tenant intentionally triggers 10+ failures to disable a plugin they don't like. | **F-013** — the tracker counts only `sandbox_failed` / `signature_invalid` / `binary_load_failed` (configured at `plugin-invoke.ts:150-155`). A tenant cannot trigger `signature_invalid` from a user-facing path, but they can trigger `sandbox_failed` if they control the plugin input. Acceptable: a tenant disabling their own plugin is not a privilege escalation. | | **Elevation** | Bypass auto-disable by ensuring the counter resets. | `recordSuccess` decrements (`crash-tracker.ts:138-152`) but only by 1 per success. Threshold-1 successes still leave threshold-1 failures. Auto-disable resets the counter (`crash-tracker.ts:127`) — re-enable starts at zero, which is intentional. | None. | ### A-11 — Sandbox memory | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Spoofing/Tampering** | Plugin reads/writes outside its memory region. | WebAssembly's memory-safety guarantees + Node's V8 enforce bounds. Trap → `memory_limit_exceeded` (`runtime.ts:176-179`). | V8-implementation bugs are out of plugin's control; tracked by Chrome security advisories. Operator must keep Node.js patched. | | **DoS** | Memory-bomb grows past the cap. | `maximum` page count enforced by `WebAssembly.Memory({ initial, maximum })` (`runtime.ts:117-118`). | None. | | **Elevation** | Memory escape to host. | V8 sandbox boundary. **This is the audit's principal Q.** | Mitigated by all the layers above plus operator hardening (seccomp/AppArmor — see Security Notes §worker-isolation in `wasm-sandbox.md`). | ### A-12 — Plugin output buffer | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Info disclosure** | Plugin reads tenant DB rows, packs them into its output, output lands in Sentry. | (1) `sanitizePluginOutput` (`packages/plugin-runtime/src/sanitize.ts`) redacts PANs/IBANs/JWTs/API-keys; field-name awareness catches `password`/`secret`. (2) Applied at `apps/worker/src/jobs/plugin-invoke.ts:248` before persistence. (3) Strictness from env `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` (default `moderate`). | Regex-based sanitization is best-effort: a plugin that base64-encodes secrets evades the regex. **Sanitization is defence-in-depth — the primary trust boundary is the capability gateway** (no `db.read` → can't read tenant data). | | **DoS** | Plugin returns a 10 MB string into the audit. | F-014 is closed: `sanitizePluginOutput` truncates large strings and carries a total output budget across the walk (`PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`, default 1 MiB); once exhausted, further string leaves become `[SIZE_LIMIT]`. | Output-size exhaustion is bounded. Regex sanitization remains best-effort for encoded secrets; see the Info disclosure residual above. | ### A-13 — Rate-limit counters | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | **DoS** | Single tenant exhausts worker capacity. | `consumeRateLimit` seam (`plugin-invoke.ts:208-212`) — when `allowed=false` the handler returns `rate_limit_exceeded` before invoking the sandbox. F-015 is mostly closed: the production plugin worker bootstrap wires a SharedStore-backed limiter, and missing production deps fail loudly rather than silently acknowledging plugin jobs. | Remaining external signoff is the broader production WASM runtime review, not the rate-limiter wiring itself. | ### A-14 — Plugin worker emergency stop | STRIDE | Attack scenario | Mitigation(s) | Residual risk | | ---------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | **DoS / availability** | After a sandbox-escape report, runtime must be killable quickly. | Stop/scale the dedicated `worker-plugin` service to zero (or redeploy it with `WORKER_ENABLED=false`), pause/drain `toolctrl.plugin-invoke`, and disable affected plugins through lifecycle controls. | Residual operator risk: this is an operational stop path, not a runtime env flag. Runbooks and deploy tooling must keep `worker-plugin` separately controllable from core workers. | --- ## 3. Cross-cutting invariants These invariants apply to every asset above. Any code change that weakens an invariant is a security-relevant change and must be flagged in PR description. 1. **`tenantId` is host-injected, never plugin-supplied.** Every plugin-runtime function that takes a tenant id reads it from a host-side source (URL param, job payload from outbox event, etc.). The plugin SDK gives plugins no API to set their own tenant id. 2. **Capabilities are deny-by-default and closed-enum.** `PLUGIN_CAPABILITY_NAMES` is the trust boundary. Adding a capability requires (a) extending the enum, (b) implementing a host-fn, (c) reflecting in the manifest schema, (d) reflecting in this threat model. 3. **Audit sink failure is fail-closed in the production plugin worker.** Invocation audit writes throw on persistence failure, and capability-use telemetry is configured with `failClosed: true` in `plugin-invoke-bootstrap.ts`. Local/dev recorder callers may still keep best-effort telemetry by omitting `failClosed`. 4. **Plugin output is sanitized before it leaves the worker process.** Any new code path that emits plugin-output bytes (OTel spans, Sentry breadcrumbs, additional log statements) must funnel through `sanitizePluginOutput`. Re-verify on every PR touching `plugin-invoke.ts` or future observability wiring. 5. **No live mutation of the trust root.** `PLUGIN_TRUSTED_PUBLIC_KEYS` is read once at boot. Restart-to-rotate is intentional. 6. **Plugin migrations are CREATE-only.** No `UPDATE`/`DELETE`/`ALTER`/`DROP` allowed in the foundation grammar. 7. **Production plugin operations are explicitly gated.** In production, lifecycle/fanout/runtime operations require `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`; the older runtime-enable flag is not the current gate. --- ## 4. Out-of-scope (handled elsewhere) - **Container hardening (seccomp/AppArmor, user/group, capability(7) drops on the `worker-plugin` pod).** Tracked in [Plugin Lifecycle Operations](../operations/plugin-lifecycle.md) and the external runtime signoff package. - **CI worker integrity.** The pipeline that produces these binaries is covered by `docs/security/threat-model.md`. - **DB row encryption at rest.** Postgres-level — operator responsibility. - **Marketplace UX, plugin-discovery, plugin-store-rating.** Not built; tracked in Phase 11 backlog. --- ## 5. Change log | Date | Change | Author | | ---------- | ----------------------------------------------- | ---------------------------- | | 2026-05-19 | Initial draft for B-006 external audit package. | Claude Opus 4.7 (1M context) |