Files
HiveGrid-Wireframes/hivegrid-cloud/docs/security/plugin-internal-findings.md
T

37 KiB

Plugin Runtime — Internal Self-Audit Findings

Phase: 06 Author: Claude Opus 4.7 (1M context) Last revised: 2026-05-19 Status: Initial cut for B-006 audit-package. Findings here are the gaps the agent identified during the threat-model + attack-surface authoring pass. They are NOT a substitute for the external pen-test; they are the input the auditor receives to know what we already know.

Each finding maps back to an asset in docs/security/plugin-threat-model.md (A-NN) and a folder-per-finding in docs/improvement-plan/06-plugin-system/security-findings/F-NNN-…md if/when it reaches Critical / High status.

Severity scale here: Critical = production-blocker, High = remediate before sign-off, Medium = remediate or document as accepted residual, Low = backlog.


Summary

ID Severity Asset Status One-line
F-001 Medium A-02 backend closed 2026-05-19; list-level UI closed 2026-05-26; nonrepudiation metadata closed 2026-05-28; guided dialog deferred TenantPluginCapabilityGrant Prisma model + migration 20260519010000_06_plugin_capability_grant. grantPluginCapabilities service upserts one row per (tenant, plugin, capability); rejects capabilities not declared in the manifest. enablePlugin runs assertConsentSatisfied and refuses to flip Plugin.enabled=true until every declared capability has a grant (capability_consent_missing → HTTP 409). Disable always allowed (kill-switch parity). Route exposes POST ?op=grant with { pluginId, grants[] }. Env opt-out PLUGIN_REQUIRE_CAPABILITY_CONSENT=false for back-compat dev/test. Tenant settings now requires a reviewed-capabilities checkbox and server-generated manifest digest before writing missing grants; stale manifests are rejected. Grant audit events include plugin version, review digest, approved capability names, approved scope JSON, and the matching manifest scope snapshot. A fuller guided tenant-admin consent dialog remains deferred.
F-002 Medium A-01, A-11 partially closed 2026-05-27; Wasmtime track remains external hardening Plugin invocation now runs in a killable worker-thread sandbox and enforces PLUGIN_MAX_WALL_CLOCK_MS (default 5000 ms) with worker.terminate(), so a pure-WASM loop with zero host calls no longer blocks the parent worker indefinitely. Remaining hardening: V8 WebAssembly still lacks per-instruction fuel/epoch interruption, so CPU accounting is wall-clock coarse and a future Wasmtime/Component-Model runtime remains the preferred security-audit upgrade path. Keep tight PLUGIN_MAX_FUEL, per-plugin worker concurrency, and BullMQ job timeouts in production.
F-003 Low A-05 closed 2026-05-19; operator verifier shipped 2026-05-29 Per-tenant hash chain landed: PluginCapabilityUse.prevHash + rowHash columns (migration 20260519000000_06_plugin_capability_use_chain), canonical-form spec in packages/plugin-runtime/src/audit-chain.ts (10-field deterministic encoding, SHA-256), worker handler stamps both columns per batch with optional getChainHead seam so chains span batches. verifyPluginCapabilityUseChain walks an ordered slice and reports first break with prev_mismatch / row_hash_mismatch. Chain-helper, handler, and CLI tests cover stamping, cross-batch seeding, soft-fail on getChainHead error, intra-batch threading, tamper/deletion detection, and operator verification through toolctrl-cli plugins verify-audit-chain <tenantId>. Pre-F-003 rows are skipped without flagging. A production web endpoint remains deferred; the operator CLI is the supported verification surface.
F-004 (reserved) — — placeholder for IDs that get split out from F-005.
F-005 High A-02, A-08 closed 2026-05-19 (Phase A + Phase B) Phase A (commit 5d9b97cd): trust-store gate + signedBy/key match. Phase B (this commit): install path now reads the .wasm binary from PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm and runs verifyPluginSignature against the manifest. Rejects with binary_not_found (404), binary_verification_failed (403), or signature_key_* (403). Dev escape via PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true documented as non-production. 4 new tests with temp-dir fixture cover missing binary, hash mismatch, submitted-sha-vs-manifest-sha mismatch, and the escape hatch.
F-006 Low A-06 closed 2026-05-19 Stage-2 token-aware scan landed in migrations.ts: tokenizeSqlIdentifiers walks the SQL respecting single-quoted strings, double-quoted identifiers, dollar-quoted strings, line comments, and block comments. findForbiddenKeyword scans the identifier stream for DROP DATABASE / GRANT / REVOKE / TRUNCATE / SET ROLE / PG_*. Replaces the previous substring-only check (which both false-positived on string-literal content AND was bypassable through obfuscation). 7 new tests cover string-literal isolation, dollar-quote isolation, comment isolation, keyword detection, and the previously-broken DEFAULT-with-grant false-positive case. Stage-1 substring scan retained as belt-and-braces; only fires when a forbidden token appears OUTSIDE every string literal.
F-007 Low A-06 closed 2026-05-19 applyPluginMigration now emits a plugin.migration.execute_statement info line BEFORE each executeRawUnsafe. Includes statementIndex, statementCount, and a 200-char statementPreview. The matching execute_failed log also includes the statement index. 2 new tests.
F-008 Medium A-06 closed 2026-05-19 validatePluginMigration now caps statement count at DEFAULT_MAX_STATEMENTS_PER_MIGRATION (32); per-call maxStatements override available. Code too_many_statements on PluginMigrationError. 3 new tests.
F-009 Medium A-07 closed 2026-05-19 installPlugin now rejects when the tenant has reached PLUGIN_MAX_INSTALLS_PER_TENANT (default 50; set to 0 to disable). New code tenant_install_limit_exceeded → HTTP 429. 3 new tests.
F-010 Low A-08 closed (test landed 2026-05-19, commit bf639ac7) CSRF body-consumption regression — verified safe when x-csrf-token header is set; install route documents this contract. Negative-path test confirms a clean error (not the opaque undici TypeError: unusable) when callers omit the header.
F-011 Medium A-08 closed 2026-05-19 Install route now runs a SharedStore sliding-window rate limiter (createSlidingWindowRateLimiter) keyed on tenant:<id>. Default PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5; over-limit returns 429 + Retry-After: 60. Production fails closed on infra errors unless PLUGIN_RATE_LIMIT_FAIL_OPEN=true is explicitly configured; non-production remains fail-open for operator ergonomics. 3 new tests cover allowed/denied/infra-error paths.
F-012 Medium A-09 closed 2026-05-26 Plugin hook fanout is now wired: plugin.hook.* outbox events route to outbox.plugin-hook, the general worker's plugin-hook-fanout consumer scopes enabled-plugin lookup by tenantId, filters manifest-declared hooks, and enqueues one idempotent toolctrl.plugin-invoke job per subscriber. Sandbox execution remains isolated on WORKER_QUEUE_GROUP=plugin. The fanout path is tenant-scoped and does not cross tenant boundaries.
F-013 Low A-10 accepted-residual 2026-05-19 A tenant user with control over plugin input can drive their own plugin to auto-disable. Confirmed not a privilege escalation: the threat-model invariant is that disabling a plugin for one's own tenant is not a security boundary. Documented in plugin-threat-model.md A-10 "Elevation" row.
F-014 Medium A-12 closed 2026-05-19 sanitizePluginOutput now carries a total-bytes budget across the walk (default 1 MiB, env override PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES). Once exhausted further string leaves become [SIZE_LIMIT] markers. The output-size budget is closed; the remaining caveat is regex-based sanitization for encoded secrets. 4 new tests cover budget exhaustion in objects + arrays, default-fits cases, and invalid-env fallback.
F-015 Critical A-13 bootstrap wiring closed 2026-05-26; production runtime gate remains B-006 Handler now dispatches to handlePluginInvokeJob when pluginInvokeDeps is injected; throws (fail-loud, BullMQ dead-letters) when not. apps/worker/src/index.ts composes production deps when WORKER_QUEUE_GROUP=plugin. Bootstrap module apps/worker/src/jobs/plugin-invoke-bootstrap.ts wires Prisma loadPlugin, filesystem loadBinary (from PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm), verifyPluginSignature, SharedStore sliding-window rate-limit, sandbox+gateway invokeInSandbox, log-based recordAudit, and SharedStore crash-tracker. Remaining signoff is the broader production WASM runtime review, not the SharedStore limiter wiring. Host-fns for the 9 declared capabilities remain empty — any capability call denies via no_host_implementation through the gateway (intentional foundation scope).
F-016 Low A-14 documented 2026-05-19 The stale runtime-enable toggle has been replaced by the current production gate: PLUGIN_RUNTIME_PRODUCTION_ENABLED=true is required before tenant plugin runtime operations are allowed in production. Emergency stop remains operational: stop/scale worker-plugin, pause/drain toolctrl.plugin-invoke, and disable affected plugins through lifecycle controls.
F-017 High A-02 closed (commit 0b2995ad, 2026-05-19) Schema widened to declare optional minHostVersion / maxHostVersion. PluginLifecycleService.extractVersionConstraints is now redundant (the parsed manifest already carries the fields) but remains harmless; consider collapsing in a follow-up.
F-021 Medium A-06 closed 2026-05-19 validateCreateTable now requires tenantId to appear as a column-definition header (preceded by ( or ,, optionally double-quoted, followed by whitespace before the type). Column comments / DEFAULT literals containing the word tenantId no longer satisfy the check. 5 new tests cover the positive + negative paths.
F-022 Medium A-08 closed 2026-05-26 Plugin lifecycle routes now split the broad account-settings permission into app.plugins.use for read/list/audit surfaces and app.plugins.manage for install, capability grant, enable, and disable. Default seeds assign manage to SuperUser/Admin and use to all default app roles.
F-023 Medium A-12 closed 2026-05-19 sanitizePluginOutput.walk() now carries a WeakSet seen-tracker; circular refs are replaced with [CIRCULAR] instead of overflowing the stack. 3 new tests cover self-reference, two-step cycle, and circular array.
F-018 Medium A-12 documented 2026-05-19 Sanitization is best-effort regex defence-in-depth against PANs / JWTs / IBANs / API-keys; a plugin that custom-encodes data evades it. Confirmed: the primary trust boundary remains db.read capability denial — a plugin that cannot read tenant data cannot exfiltrate it. Documented in plugin-threat-model.md A-12 "Info disclosure" row and the cross-cutting invariant §3.4.
F-019 Medium A-04 documented 2026-05-19 Capability gateway onCall / onDenied callbacks fire after the host-fn returns or throws. Verified by reading capability-gateway.ts:121-137: success outcome before return, error outcome before re-throw. The recorder always sees an outcome — there is no "lost" call. Documented in plugin-threat-model.md A-04. When real host-fn implementations land each will need to keep this invariant; tracked as a per-PR check item.
F-020 Low A-05 closed 2026-05-19 Retention job now logs surviving row count after each sweep. When count exceeds warnRowCountThreshold (default 10M) the job emits a plugin-audit-retention.table-size-warning warn line. count() soft-fails so a flaky DB doesn't poison the retention result. Worker bootstrap wires prisma.pluginCapabilityUse.count(). 4 new tests.

F-005 (High, closed) — Install path re-verifies binary signature

Severity: High (closed 2026-05-19, Phase A + Phase B) Asset: A-02 Plugin manifest, A-08 Tenant install API

Description

apps/web/app/routes/api.tenants.$tenant.plugins.tsx still delegates install requests to installPlugin({ tenantId, manifestJson, wasmSha256, signaturePublicKey, actor }), but the lifecycle service no longer stores caller-supplied binary metadata without verification.

Installs are gated, not accepted merely because a manifest is valid. The lifecycle service requires PLUGIN_RUNTIME_PRODUCTION_ENABLED=true in production, parses the manifest, enforces inclusive minHostVersion / maxHostVersion when present, checks the trusted publisher key list, requires signaturePublicKey to match manifest.signedBy, reads the pre-positioned binary from PLUGIN_BINARY_BASE_PATH/<pluginId>/<version>/plugin.wasm, runs verifyPluginSignature, cross-checks submitted SHA vs manifest.wasmSha256, rejects duplicate tenant/plugin installs, and enforces the tenant install cap. Production must not set PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true.

Reproduction

curl -X POST https://staging/api/tenants/<tenant>/plugins \
  -H "x-csrf-token: <token>" \
  -H "content-type: application/json" \
  -d '{
    "manifestJson": <manifest object pointing at a published binary>,
    "wasmSha256": "0000000000000000000000000000000000000000000000000000000000000000",
    "signaturePublicKey": "<some-published-public-key>"
  }'
# Current result: 403 with "binary_verification_failed"; non-production
# environments may opt into PLUGIN_INSTALL_SKIP_BINARY_VERIFY for local tests.

Closure evidence

  • PluginLifecycleService.installPlugin runs the production gate, parses trusted keys via parseTrustedPublicKeys, checks manifest.signedBy === signaturePublicKey, enforces the host-version range, loads the .wasm from disk, calls verifyPluginSignature before create, rejects duplicate (tenantId, pluginId) rows, and enforces PLUGIN_MAX_INSTALLS_PER_TENANT.
  • Missing binaries fail with binary_not_found; invalid hashes/signatures fail with binary_verification_failed; untrusted or mismatched publisher keys fail with signature_key_*.
  • Invocation-time verification in apps/worker/src/jobs/plugin-invoke.ts remains as a second line of defence.

F-010 (closed) — CSRF body-consumption bug from BLOCKERS B-009

Severity: Low (closed) Asset: A-08 Tenant install API

Description

BLOCKERS.md B-009 originally noted a follow-up bug: older validateCSRFToken behavior could surface TypeError: unusable whenever a route action had already read the body before validation.

The plugin install action (api.tenants.$tenant.plugins.tsx:43) does:

await validateCSRFToken(request);   // line 46
const body = (await request.json()) as { ... };   // line 63

CSRF validation happens before the JSON body is read. The current validator also keeps the JSON body safe by checking x-csrf-token before any body fallback, and by exposing { formData } for single-read form actions.

Reproduction

# Form-data POST attempting CSRF + JSON body
curl -X POST https://staging/api/tenants/<tenant>/plugins \
  -H "x-csrf-token: <token>" \
  -d '{"manifestJson":...}' \
  -H "content-type: application/json"
# Expected: 201 Created (if both CSRF + JSON body work)
# Actual: verified 201 when the header token is present; clean CSRF error when absent

Remediation plan

  1. Add an integration test apps/web/app/routes/api.tenants.$tenant.plugins.test.ts that hits the install path with content-type: application/json and an x-csrf-token header to verify the happy path.
  2. Keep JSON callers on the header-token contract.
  3. Use validateCSRFToken(request, { formData }) for form actions that intentionally parse the body before validation.

Remediation commits

SHA Summary
bf639ac7 test(plugin-routes): verify install route immunity to CSRF body-already-consumed (F-010)

Verification outcome (2026-05-19)

  • The real validateCSRFToken short-circuits at session.server.ts:250 whenever the x-csrf-token header is present, before any request.clone().text() call. The install route therefore never triggers the body-clone path when callers honour the header contract.
  • The new test pair in apps/web/app/routes/api.tenants.$tenant.plugins.test.ts replaces the csrf mock with a partial real implementation that mirrors header-first / body-fallback. With the header set, install returns 201 and request.json() succeeds afterwards. Without the header, the test surfaces a clean CSRF Request Token not included. error (not the opaque undici TypeError: unusable) — proving the route fails cleanly even in the worst case.
  • Downgraded from High → Low and closed.
  • 2026-05-25 follow-up: validateCSRFToken(request, { formData }) is now covered in apps/web/app/utils/session.server.test.ts, closing the broader UI form-body ordering bug that originally prompted the B-009 note.

F-015 (Critical, bootstrap wiring closed) — Verify production rate-limit wiring

Severity: Critical for production signoff; internal bootstrap wiring closed Asset: A-13 Rate-limit counters

Description

apps/worker/src/jobs/plugin-invoke.ts:91 declares consumeRateLimit as a dependency seam. Tests inject a fake. The handler returns rate_limit_exceeded when allowed: false.

The production worker bootstrap now wires a real SharedStore-backed limiter. A no-op would allow a single plugin to saturate the worker pool, so this remains an explicit audit checkpoint whenever production bootstrap code changes.

Reproduction

Read apps/worker/src/jobs/plugin-invoke-bootstrap.ts wherever handlePluginInvokeJob is wired in production. Confirm consumeRateLimit calls a SharedStore-backed sliding-window or token-bucket. If a future refactor returns { allowed: true, remaining: Infinity }, the rate limit has regressed to a no-op.

Remediation plan

  1. Grep for consumeRateLimit usage in apps/worker/src/.
  2. Verify the wired implementation; if no-op, replace with a real SharedStore-backed limiter.
  3. Add a test that asserts the production wiring rate-limits at the configured window.

Remediation commits

SHA Summary
cbac8d3d Wire production plugin invoke bootstrap with SharedStore rate limiting.

Verification outcome (2026-05-26) — bootstrap wiring closed

F-015's internal bootstrap gap is closed in code. apps/worker/src/jobs/plugin-invoke-bootstrap.ts wires handlePluginInvokeJob into the dedicated WORKER_QUEUE_GROUP=plugin runtime with Prisma plugin lookup, binary loading, signature verification, SharedStore-backed rate limiting, sandbox invocation, audit logging, and crash tracking.

The generic handler path in apps/worker/src/handlers.ts now fails loudly if pluginInvokeDeps is missing, so a misconfigured worker dead-letters the job instead of silently acknowledging it as skipped. Current coverage includes the plugin invoke handler/bootstrap tests and deploy topology verification that checks worker-plugin as a separate Coolify resource.

Remaining external signoff is the production WASM security review tracked by B-006 and docs/security/plugin-runtime-signoff.md; it is not a blocker for the F-015 bootstrap wiring itself.


F-017 (closed) — Manifest schema declares host-version constraint fields

Severity: High (closed 2026-05-19, commit 0b2995ad) Asset: A-02 Plugin manifest

Description

Historically, packages/plugin-runtime/src/version-check.ts checked manifest.minHostVersion and manifest.maxHostVersion, but the strict manifest schema did not declare those fields. That made host-version constraints unusable because manifests carrying them failed schema parsing before the compatibility check ran.

Current status: the schema now declares optional minHostVersion / maxHostVersion fields, and install-time lifecycle validation enforces the inclusive host-version range before a plugin row is created.

Reproduction

Historical reproduction only:

  1. Submit a manifest with minHostVersion: "1.0.0" before commit 0b2995ad.
  2. Expected: install succeeds or fails with a host-version compatibility error.
  3. Historical result: install failed with manifest_invalid because Zod's .strict() rejected the unknown field.

Closure evidence

  • packages/plugin-runtime/src/manifest.ts accepts optional minHostVersion and maxHostVersion.
  • packages/plugin-runtime/src/version-check.ts validates the parsed manifest bounds against the running host version.
  • PluginLifecycleService.installPlugin rejects incompatible plugins before create.

Remediation commits

SHA Summary
0b2995ad fix(plugin-runtime): widen manifest schema to declare host-version constraint fields (F-017)

Verification outcome (2026-05-19)

  • Both fields added to PluginManifestSchema as optional z.string().regex(SEMVER_PATTERN). Schema remains .strict().
  • Four new tests in manifest.test.ts: accepts omitted, accepts well-formed pair, rejects non-SemVer min, rejects non-SemVer max. Existing 13 tests + 11 version-check tests still pass.
  • PluginLifecycleService.extractVersionConstraints is now redundant — the parsed manifest already carries the fields. Left in place because removing it requires touching out-of-scope PluginLifecycleService.ts per the session policy. Worth collapsing in a follow-up PR (no behaviour change expected — both paths land on the same struct via { ...manifest, ...rawConstraints }).
  • Closed.

Other findings (Medium / Low)

The remaining findings F-001, F-002, F-003, F-006, F-007, F-008, F-009, F-011, F-013, F-014, F-016, F-018, F-019, F-020 are tracked in the summary table above. Each becomes a stub file under docs/improvement-plan/06-plugin-system/security-findings/F-NNN-….md if and when it is scheduled for remediation.


Change log

Date Change Author
2026-05-19 Initial 20-finding cut produced during threat-model pass. F-005/F-010/F-015/F-017 flagged as High for sign-off remediation. Claude Opus 4.7 (1M context)
2026-05-19 Self-audit pass. F-017 closed via commit 0b2995ad (schema widened). F-010 closed via commit bf639ac7 (regression test landed; route confirmed safe under x-csrf-token header contract). F-005 confirmed open + concrete remediation plan added (trust-store gate as Phase A, in-PR scope; binary-positioning gate as Phase B, out of scope). F-015 escalated High → Critical during this pass because production plugin invoke wiring and rate limiting still needed proof at that point. Three new findings added: F-021 (migration tenantId-column regex bypass, Medium), F-022 (permission conflation across all plugin lifecycle ops, Medium), F-023 (sanitize missing circular-reference guard, Medium). Claude Opus 4.7 (1M context)
2026-05-26 Docs sync: F-012 updated to closed after plugin.hook.* events gained the outbox.plugin-hook route and the general-worker plugin-hook-fanout consumer that enqueues tenant-scoped toolctrl.plugin-invoke jobs for manifest-declared subscribers. Codex