26 KiB
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_<sanitized-id>_ tables only. |
| 4 | storage.read |
Declared; host-fn deferred | TBD | none | KV reads from SharedStore, prefixed plugin:<id>:tenant:<tid>:. |
| 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.
logandauditare routed throughinvokeSync(...)and deny if undeclared or not tenant-granted for this invocation. Deferred host-fns still throwCapabilityDeniedErrorwithreason="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_<sanitized-id>_<table> |
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_<sanitized-id>_* only, tenant-scoped via injected WHERE tenantId = $tenant. |
storage.* |
None today; SharedStore KV host-fns are deferred. | Per design: prefix plugin:<id>:tenant:<tid>:. |
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=<rowId>&op=enable |
Same | JSON {} |
Enable. |
| POST | /api/tenants/:tenant/plugins?id=<rowId>&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
PluginCapabilityUserows (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
pluginIdrejection, 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.publishcapability. Plugins cannot emit arbitrary outbox events. - No
process/child_process/eval/Functionexposure to the plugin. The host-imports map (runtime.ts:128) is closed. - No file-system access from plugin code. No
wasi:filesystemimport is provided. - No network egress except through
http.outbound. Whenhttp.outboundlands, 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 onetenantId; the worker scopes thePluginlookup 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) |