# Plugin Runtime Attack Surface Inventory **Phase:** Phase 06 **Author:** Claude Opus 4.7 (1M context) **Last revised:** 2026-05-29 **Status:** Companion to `docs/security/plugin-threat-model.md`. Read that first. This document enumerates **every entry point** a third-party WASM plugin (or a tenant admin acting on behalf of one) can reach through code in the boilerplate. The model lists the _threats_ per asset; this document lists the _interface_ — what concrete API or host-binding accepts plugin-controlled bytes, and what tables they can mutate. If you add a new host-fn, a new capability, a new manifest field, a new admin endpoint, or a new DB table that a plugin can touch — **append it here in the same PR**. The external auditor uses this as the testing-checklist. --- ## 1. Host bindings exposed to WASM Bindings are mediated by the **capability gateway** (`packages/plugin-runtime/src/capability-gateway.ts:78`). The gateway exposes one host-fn per declared capability. Foundation scope ships **9 capabilities**, defined in the closed enum `PLUGIN_CAPABILITY_NAMES` (`packages/plugin-runtime/src/capabilities.ts:13-23`). | # | Capability | Status | Host-fn implementation site | Manifest scope shape | Notes | | --- | --------------- | --------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | 1 | `http.outbound` | **Declared, gateway-routed; production host-fn deferred** | TBD (was scheduled in `wasm-sandbox.md` §Host-Bindings) | `{ allowedHosts: string[] }` — `packages/plugin-runtime/src/manifest.ts:60-63` | When wired: outbound HTTP to declared hosts only; body-size cap; per-call timeout. | | 2 | `db.read` | **Declared; host-fn deferred** | TBD | none (foundation) | Will inject `WHERE tenantId = $tenant` server-side and append `LIMIT 1000`. | | 3 | `db.write` | **Declared; host-fn deferred** | TBD | `{ tablePrefix: string }` — `packages/plugin-runtime/src/manifest.ts:64-67` | Write must hit `plugin__` tables only. | | 4 | `storage.read` | **Declared; host-fn deferred** | TBD | none | KV reads from SharedStore, prefixed `plugin::tenant::`. | | 5 | `storage.write` | **Declared; host-fn deferred** | TBD | none | Same prefix; default 24h TTL. | | 6 | `log` | **Declared; worker host-fn wired** | `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` | none | Structured worker log tagged with `pluginId` and `version`. | | 7 | `audit` | **Declared; worker host-fn wired** | `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` | none | Structured worker log tagged with `pluginId` and `version`; durable redacted `plugin.audit.recorded` audit event is enqueued. | | 8 | `tenant.info` | **Declared; host-fn deferred** | TBD | none | Read-only tenant metadata: id, slug, plan. | | 9 | `secret.read` | **Declared; host-fn deferred** | TBD | `{ keyPattern: string }` — `packages/plugin-runtime/src/manifest.ts:63` | Tenant-scoped plugin secrets; key must match pattern. | > **Audit implication:** every host-fn above is gated by the gateway. `log` and `audit` are routed through `invokeSync(...)` and deny if undeclared or not tenant-granted for this invocation. Deferred host-fns still throw `CapabilityDeniedError` with `reason="no_host_implementation"`. A plugin cannot bypass the declared or approved capability set; the attack surface today is the gateway, the two safe worker log imports, and the remaining manifest-validatable capability names. ### Always-on host imports Independent of the gateway, the sandbox provides: | Import | Implementation | Notes | | -------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `env.memory` | `WebAssembly.Memory({ initial: 1, maximum: maxPages })` at `packages/plugin-runtime/src/runtime.ts:118` | Default cap 64 MiB (`runtime.ts:73`). | | `env.hivegrid_log(i32)` | `gateway.invokeSync("log", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `log`; emits `plugin.invoke.host.log` worker log. | | `env.hivegrid_audit_record(i32)` | `gateway.invokeSync("audit", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `audit`; emits `plugin.invoke.host.audit` worker log. | | `env.hivegrid_payload_len()` | Current invocation payload byte length at `runtime.ts:132` | Returns the UTF-8 byte length of the JSON-encoded payload. | | `env.hivegrid_payload_byte(i32)` | Current invocation payload byte read at `runtime.ts:133-136` | Returns one byte from the UTF-8 JSON payload, or `0` outside bounds. | | Hook entry-point export call | `(hook as Fn)(arg)` at `runtime.ts:176` | Numeric payloads still pass as the scalar `arg`; object/array/string payloads pass `0` and are read through the JSON payload ABI imports above. | No `wasi:filesystem`, `wasi:cli`, `wasi:sockets`, `wasi:random`, `wasi:clocks`, or other WASI modules are imported. A plugin that declares such an import fails instantiation with `instantiate_failed` (`runtime.ts:131-141`). --- ## 2. Manifest fields (plugin-controlled input) The manifest is parsed by `parsePluginManifest` (`packages/plugin-runtime/src/manifest.ts`) using `PluginManifestSchema` (`packages/plugin-runtime/src/manifest.ts:128-147`). All fields are `.strict()` — unknown keys are rejected. | Field | Validation | Auditor checks | | ------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `schemaVersion: 1` | `z.literal(1)` (`manifest.ts:130`) | Version bump = breaking-change opportunity. | | `id` | Regex `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` (`manifest.ts:21,131`) | Reverse-DNS only. Cannot contain `..`, `/`, `\`. Used as SQL prefix via `sanitizePluginIdForSql` (`migrations.ts:28`). | | `version` | Regex `^[0-9]+\.[0-9]+\.[0-9]+(-…)?$` (`manifest.ts:26,132`) | Loose SemVer. Compatibility check via `version-check.ts`. | | `name` | `min(1).max(256)` (`manifest.ts:133`) | Used as UI display. **HTML-escape required at render** — check `apps/web/app/routes/app.$tenant/settings/plugins.tsx`. | | `description` | `max(2048).optional()` (`manifest.ts:134`) | Same as above. | | `entry` | `min(1).max(256)` (`manifest.ts:136`) | Path to .wasm within the plugin bundle — operator-side. | | `wasmSha256` | Regex `^[0-9a-f]{64}$` (`manifest.ts:138`) | Strict hex-64. | | `signature` | Regex `^[0-9a-f]{128}$` (`manifest.ts:140`) | Strict hex-128. | | `signedBy` | Regex `^[0-9a-f]{64}$` (`manifest.ts:142`) | Strict hex-64 = Ed25519 public key. | | `allowedHosts[]` | `HostnameSchema` (`manifest.ts:50`) | Public hostnames only — no scheme, port, localhost, IP literal, `.local`, or `.internal`. Used by `http.outbound` host-fn (deferred). | | `capabilities[]` | `z.enum(PLUGIN_CAPABILITY_NAMES)` + scope subset (`manifest.ts:72-77`) | Closed enum. Scope keys are an explicit `.strict()` subset (`manifest.ts:60-70`). | | `hooks[]` | `name.min(1).max(128)`, `export.min(1).max(128)` (`manifest.ts:79-84`) | At least 1 hook required (`manifest.ts:146`). | | `ui.settings.entry` | Relative bundle HTML path; no scheme, absolute path, or `..`; `max(256)` (`manifest.ts:86-93`) | Optional settings iframe entry. Must end in `.html`; parent route adds sandbox/CSP and PostMessage bounds. | | `minHostVersion` | Loose SemVer string, optional | Inclusive lower host bound. Install rejects out-of-range plugins with `incompatible_host_version`. | | `maxHostVersion` | Loose SemVer string, optional | Inclusive upper host bound. Omitting both host-version fields means any host version is acceptable. | --- ## 3. DB tables a plugin can read or mutate ### 3.1 Tables a plugin owns (created via its own migrations) | Pattern | Constraint | Source | | ------------------------------- | -------------------------------------------------------------------------- | ----------------------- | | `plugin__` | Must start with the prefix derived from `sanitizePluginIdForSql(pluginId)` | `migrations.ts:99-104` | | ⤷ `tenantId TEXT NOT NULL` | Mandatory column on every plugin table | `migrations.ts:106-110` | A plugin **cannot** create tables outside its prefix; cannot use `ALTER`/`DROP`/`UPDATE`/`SELECT` in migrations (only `CREATE TABLE` / `CREATE [UNIQUE] INDEX`); cannot reference `pg_*` system tables (`migrations.ts:69-81`). ### 3.2 Tables a plugin reads or writes via host-bindings (when wired) | Capability | Tables touchable | Scope | | ------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `db.read` | Currently none until `db.read` host-fn lands. **Auditor: confirm no shortcut path exists today.** | Per design: tenant-scoped read, `LIMIT 1000`. | | `db.write` | Currently none until `db.write` host-fn lands. | Per design: `plugin__*` only, tenant-scoped via injected `WHERE tenantId = $tenant`. | | `storage.*` | None today; SharedStore KV host-fns are deferred. | Per design: prefix `plugin::tenant::`. | | `audit` | Durable `AuditEvent` outbox path for redacted plugin-emitted audit records; no plugin-owned table access. | Plugin-id and tenant-id are host-injected. | | `tenant.info` | Currently none until `tenant.info` host-fn lands. | Per design: read-only tenant metadata for the current tenant only. | | `secret.read` | Currently none until encrypted plugin secret storage and `secret.read` host-fn land. | Per design: tenant + plugin + key-pattern scoped. | ### 3.3 Tables a plugin can **never** touch - `User`, `Tenant`, `Session`, `ApiKey`, `AuditEvent`, `OutboxEvent`, any business-domain table. - `Plugin`, `PluginUserConfig`, `PluginCapabilityUse` (these are operator/host-only). - The Postgres catalogue (`pg_*`). Any code change that gives a plugin **indirect** read/write access to a table outside §3.1/§3.2 is a security-relevant change. --- ## 4. Worker queues / outbox events plugin-controlled | Queue / event stream | Worker group | Producer | Consumer / fanout target | Plugin influence | | ------------------------------ | ------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `outbox.plugin-hook` | `WORKER_QUEUE_GROUP=general` | Host code paths that emit `plugin.hook.*` outbox events (`hooks/emit.ts:91`) | Plugin hook fanout worker validates hook names/payloads and enqueues one `hivegrid.plugin-invoke` job per enabled tenant plugin. | Indirect — payload is host-validated against the hook's Zod schema before fanout. | | `hivegrid.plugin-invoke` | `WORKER_QUEUE_GROUP=plugin` | `outbox.plugin-hook` fanout on the `general` worker group | Isolated plugin worker executes the WASM invocation (`plugin-invoke.ts:137`). | Indirect — the plugin can only receive a host-produced invocation for hooks it declares. | | `outbox.plugin-capability-use` | Worker-owned outbox/audit path | Sandbox itself, via `audit.ts:127-148` | Audit-batch persister (`plugin-capability-use.ts:169`). | Direct — plugin's host-fn calls produce records; field shape validated at `plugin-capability-use.ts:78-117`. | Plugin hook fanout and plugin execution intentionally run on different worker groups: `general` owns `outbox.plugin-hook` subscription and fanout, while `plugin` owns `hivegrid.plugin-invoke` execution. This keeps third-party WASM execution isolated from general outbox dispatch and core queues. A plugin cannot **emit** an arbitrary outbox event — it has no `outbox.publish` capability (deliberate omission; would be too powerful for the foundation). --- ## 5. HTTP routes a tenant admin uses | Method | Route | Permission gate | Body | Purpose | | ------ | ---------------------------------------------------- | --------------------------- | -------------------------------------------------- | ---------------------------------- | | GET | `/api/tenants/:tenant/plugins` | `app.plugins.use` | — | List installed plugins for tenant. | | POST | `/api/tenants/:tenant/plugins` | `app.plugins.manage` + CSRF | `{ manifestJson, wasmSha256, signaturePublicKey }` | Install. | | POST | `/api/tenants/:tenant/plugins?id=&op=enable` | Same | JSON `{}` | Enable. | | POST | `/api/tenants/:tenant/plugins?id=&op=disable` | Same | JSON `{}` | Disable. | | GET | `/app/:tenant/settings/plugins` | `app.plugins.use` | — | Tenant plugin management UI. | | POST | `/app/:tenant/settings/plugins` | `app.plugins.manage` + CSRF | Form actions: grant, grantAndEnable, enable, disable, revoke | Capability consent and lifecycle. | | GET | `/app/:tenant/settings/plugins/:id` | `app.plugins.use` | — | Sandboxed plugin settings iframe. | | POST | `/app/:tenant/settings/plugins/:id` | `app.plugins.use` + CSRF | JSON `{ op: "setSetting", key, value }` or `{ op: "reportFrameViolation", reason }` | Per-user plugin settings write or redacted client-reported iframe violation audit. | | GET | `/app/:tenant/settings/plugins/:id/audit` | `app.settings.auditTrails.view` | — | Capability-use audit view. | API routes are defined in `apps/web/app/routes/api.tenants.$tenant.plugins.tsx`. Settings routes are defined in `apps/web/app/routes/app.$tenant/settings/plugins.tsx`, and production lifecycle services additionally reject install, grant, and enable attempts for manifest capabilities without reviewed host bindings. `apps/web/app/routes/app.$tenant/settings/plugins.$id.tsx`, and `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`. The settings iframe uses an opaque-origin sandbox plus CSP and accepts only the versioned `plugin.settings.ready|get|set` PostMessage protocol from its own frame window. The parent validates request id/key length, JSON-like shape, depth, array/object width, node count, and value-size budget before calling route persistence; the route also rejects oversized request bodies before `request.json()`. A tenant admin **cannot**: - Edit the WASM binary on disk (operator-only step). - Edit `PLUGIN_TRUSTED_PUBLIC_KEYS` (env, operator-only). - Read/delete `PluginCapabilityUse` rows (no API exists). - Bypass the permission gate — non-admin users get 403. A tenant admin **can** (intentional): - Install a plugin only when all install gates pass: production runtime gate, manifest/schema validation, host-version range, trusted publisher key, disk binary verification, submitted SHA check, duplicate `pluginId` rejection, and tenant install cap. - Enable/disable any of their own tenant's plugins. - Approve missing manifest-declared capability grants only after submitting the current review digest and explicit capability review confirmation; stale manifests are rejected before grants are written. - Read their tenant's audit trail at `app.$tenant/settings/plugins.$id.audit.tsx`. - Use their own per-user plugin settings UI without `app.plugins.manage`. --- ## 6. Configuration surface (operator) | Env var | Source | Effect | | ------------------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------- | | `PLUGIN_RUNTIME_PRODUCTION_ENABLED` | `production-gate.ts` | Required as `true` before tenant plugin runtime operations are allowed in production. | | `PLUGIN_TRUSTED_PUBLIC_KEYS` | `signature.ts:95` | CSV of hex Ed25519 public keys. **Root of trust.** | | `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` | `plugin-invoke.ts:39-45` | `strict` / `moderate` / `off`. Default `moderate`. | | `PLUGIN_AUDIT_RETENTION_DAYS` | `plugin-audit-retention.ts` | Days to retain `PluginCapabilityUse` rows. Default 90. | | `PLUGIN_AUDIT_RETENTION_BATCH_SIZE`, `_MAX_BATCHES`, `_CRON` | Same | Cleanup tuning. | | `PLUGIN_HOST_VERSION_OVERRIDE` | `version-check.ts` | Override the host SemVer used during compatibility check (test/staging only). | | `WORKER_CONCURRENCY_PLUGIN` | Worker bootstrap | Max parallel plugin invocations on a single worker pod. | | `PLUGIN_AUTO_DISABLE_THRESHOLD` | `crash-tracker.ts:78` | Default 10. | | `PLUGIN_AUTO_DISABLE_WINDOW_MS` | `crash-tracker.ts:79` | Default 600_000 (10 min). | | `WORKER_ENABLED=false` / scale `worker-plugin` to zero | Worker service env / Coolify resource controls | Emergency runtime stop for plugin invocation processing. | --- ## 7. Surface NOT exposed (intentional omissions) These are **explicit non-features** of the foundation. Adding any of them = new attack surface that must be threat-modelled before code lands. - **No `outbox.publish` capability.** Plugins cannot emit arbitrary outbox events. - **No `process` / `child_process` / `eval` / `Function` exposure to the plugin.** The host-imports map (`runtime.ts:128`) is closed. - **No file-system access from plugin code.** No `wasi:filesystem` import is provided. - **No network egress except through `http.outbound`.** When `http.outbound` lands, undici dispatcher will be the only path. - **No WASI, filesystem, socket, or host-object payload marshalling.** Structured hook input is exposed only through the narrow UTF-8 JSON payload ABI imports (`env.hivegrid_payload_len` / `env.hivegrid_payload_byte`). Higher-level SDK helpers and non-JSON host object bindings remain follow-up work. - **No hot-reload of binaries.** Operator restart required → no race window where two versions execute in parallel. - **No tenant cross-fan-out for hooks.** A `plugin.hook.*` outbox event carries one `tenantId`; the worker scopes the `Plugin` lookup by it. - **No live mutation of `PLUGIN_TRUSTED_PUBLIC_KEYS`.** Restart-to-rotate is intentional. --- ## 8. Test surface For the external auditor, the following test suites encode the existing assumptions. **Add new tests for any finding before remediation lands.** - `packages/plugin-runtime/src/manifest.test.ts` — manifest validation. - `packages/plugin-runtime/src/signature.test.ts` — Ed25519 verify + trust. - `packages/plugin-runtime/src/runtime.test.ts` — sandbox compile/instantiate/limits. - `packages/plugin-runtime/src/capability-gateway.test.ts` — deny-by-default. - `packages/plugin-runtime/src/audit.test.ts` — recorder soft-fail default, fail-closed mode, and max-entries. - `apps/web/app/modules/plugins/components/PluginSettingsFrame.test.tsx` — settings iframe sandbox/CSP, origin/source checks, PostMessage protocol bounds. - `apps/web/app/routes/app.$tenant/settings/plugins.$id.test.tsx` — settings route permission, CSRF, rate-limit, JSON/body validation, service error mapping. - `packages/plugin-runtime/src/crash-tracker.test.ts` — sliding-window + auto-disable. - `packages/plugin-runtime/src/migrations.test.ts` — prefix + forbidden-tokens + tenantId column. - `packages/plugin-runtime/src/sanitize.test.ts` — PII regex coverage. - `packages/plugin-runtime/src/version-check.test.ts` — host-version SemVer boundaries. - `packages/plugin-runtime/src/hooks/registry.test.ts` — registry exhaustiveness. - `apps/worker/src/jobs/plugin-invoke.test.ts` — every deny path of the worker handler. - `apps/worker/src/jobs/plugin-capability-use.test.ts` — batch insert + payload validation. - `apps/worker/src/jobs/plugin-audit-retention.test.ts` — cutoff + batching. - `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` — install / enable / disable. - `apps/web/app/modules/plugins/services/.server/PluginLifecycleService.test.ts` — lifecycle transitions. --- ## 9. Change log | Date | Change | Author | | ---------- | --------------------------------------------------- | ---------------------------- | | 2026-05-19 | Initial inventory for B-006 external audit package. | Claude Opus 4.7 (1M context) |