Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/docs/security/plugin-attack-surface.md
T
2026-07-08 16:15:37 +02:00

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. 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_<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 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)