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.installPluginruns the production gate, parses trusted keys viaparseTrustedPublicKeys, checksmanifest.signedBy === signaturePublicKey, enforces the host-version range, loads the.wasmfrom disk, callsverifyPluginSignaturebefore create, rejects duplicate(tenantId, pluginId)rows, and enforcesPLUGIN_MAX_INSTALLS_PER_TENANT.- Missing binaries fail with
binary_not_found; invalid hashes/signatures fail withbinary_verification_failed; untrusted or mismatched publisher keys fail withsignature_key_*. - Invocation-time verification in
apps/worker/src/jobs/plugin-invoke.tsremains 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
- Add an integration test
apps/web/app/routes/api.tenants.$tenant.plugins.test.tsthat hits the install path withcontent-type: application/jsonand anx-csrf-tokenheader to verify the happy path. - Keep JSON callers on the header-token contract.
- 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
validateCSRFTokenshort-circuits atsession.server.ts:250whenever thex-csrf-tokenheader is present, before anyrequest.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.tsreplaces the csrf mock with a partial real implementation that mirrors header-first / body-fallback. With the header set, install returns 201 andrequest.json()succeeds afterwards. Without the header, the test surfaces a cleanCSRF Request Token not included.error (not the opaque undiciTypeError: 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 inapps/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
- Grep for
consumeRateLimitusage inapps/worker/src/. - Verify the wired implementation; if no-op, replace with a real SharedStore-backed limiter.
- 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:
- Submit a manifest with
minHostVersion: "1.0.0"before commit0b2995ad. - Expected: install succeeds or fails with a host-version compatibility error.
- Historical result: install failed with
manifest_invalidbecause Zod's.strict()rejected the unknown field.
Closure evidence
packages/plugin-runtime/src/manifest.tsaccepts optionalminHostVersionandmaxHostVersion.packages/plugin-runtime/src/version-check.tsvalidates the parsed manifest bounds against the running host version.PluginLifecycleService.installPluginrejects 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
PluginManifestSchemaas optionalz.string().regex(SEMVER_PATTERN). Schema remains.strict(). - Four new tests in
manifest.test.ts: accepts omitted, accepts well-formed pair, rejects non-SemVermin, rejects non-SemVermax. Existing 13 tests + 11 version-check tests still pass. PluginLifecycleService.extractVersionConstraintsis now redundant — the parsed manifest already carries the fields. Left in place because removing it requires touching out-of-scopePluginLifecycleService.tsper 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 |