56 KiB
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:<pluginId>:<tenantId> |
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: <name> (<reason>)" 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 <tenantId>. |
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 SELECTs 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:<id> 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:<pluginId>:<tenantId> (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.
tenantIdis 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.- Capabilities are deny-by-default and closed-enum.
PLUGIN_CAPABILITY_NAMESis 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. - 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: trueinplugin-invoke-bootstrap.ts. Local/dev recorder callers may still keep best-effort telemetry by omittingfailClosed. - 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 touchingplugin-invoke.tsor future observability wiring. - No live mutation of the trust root.
PLUGIN_TRUSTED_PUBLIC_KEYSis read once at boot. Restart-to-rotate is intentional. - Plugin migrations are CREATE-only. No
UPDATE/DELETE/ALTER/DROPallowed in the foundation grammar. - 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-pluginpod). Tracked in Plugin Lifecycle Operations 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) |