Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,222 @@
# 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. `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.toolctrl_log(i32)` | `gateway.invokeSync("log", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `log`; emits `plugin.invoke.host.log` worker log. |
| `env.toolctrl_audit_record(i32)` | `gateway.invokeSync("audit", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `audit`; emits `plugin.invoke.host.audit` worker log. |
| `env.toolctrl_payload_len()` | Current invocation payload byte length at `runtime.ts:132` | Returns the UTF-8 byte length of the JSON-encoded payload. |
| `env.toolctrl_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 `toolctrl.plugin-invoke` job per enabled tenant plugin. | Indirect — payload is host-validated against the hook's Zod schema before fanout. |
| `toolctrl.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 `toolctrl.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 `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.toolctrl_payload_len` / `env.toolctrl_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) |