Skip to content

fix: close three gaps in the plugin gateway boundary - #7

Closed
lr00rl wants to merge 1 commit into
mainfrom
fix/plugin-gateway-hardening
Closed

fix: close three gaps in the plugin gateway boundary#7
lr00rl wants to merge 1 commit into
mainfrom
fix/plugin-gateway-hardening

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Three decision-independent findings from the 2026-07-14 plugin-boundary architecture review (lattice_plan/ARCH-REVIEW-2026-07-14-plugin-boundary.md). None depend on how the core-vs-runtime ownership question is settled.

1. /api/plugins/invoke is a raw runtime channel (Medium)

handlePluginInvoke takes an arbitrary {id, action, payload} and calls pluginRuntime.Invoke directly, gated only by plugin:admin. It consults no per-method scopes and binds no operator target — unlike /api/plugins/call, which is now the real gateway and the only path that enforces the manifest's declared scopes, binds an operator target to a single invocation, and (for host-risk work) requires a plan and an approval.

Today the blast radius is bounded: a plugin:admin operator can invoke Sub-Store's call methods bypassing their declared scopes, and Invoke (not InvokeConstrained) binds no operator target so those fail closed. The real hazard is forward-looking — once any plugin ships an execute action for the §9.3 host-risk apply protocol, this endpoint would sidestep the plan/approval/one-time-capability binding entirely and let plugin:admin apply host changes unreviewed.

The action list is now closed to describe and health. Everything else — call, plan, execute, migrate, anything — is refused with 403 and audited before the artifact runs. Nothing in the dashboard calls this endpoint (grep confirms zero callers); it is an operator diagnostic, so restricting it costs nothing.

2. Plugin-asset CSP was partly derived from the Host header (Low)

pluginAssetCSP fell back to an origin built from r.Host when publicURL was unset, injecting it into script-src/style-src. That makes the policy which confines plugin code nameable by the request it is confining. No cross-user impact (a request's Host only shapes its own response, and the frame is opaque-origin), but it is fragile and drifts from the spec's fixed 'self'.

Asset serving now fails closed (503) when publicURL is unset, and the request-derived fallback is deleted. Also adds the explicit frame-src 'none' the spec's §7.2 block lists (previously only implied by default-src 'none').

Note the existing test asserted the fallback worked — that assertion encoded the defect, so it is replaced with one asserting no request-derived host can reach the CSP.

3. Operator-target URL could leak into the audit record (Low)

Spec §9.1: "the audit record never includes the secret URL path." But extractOperatorTargets wrapped outbound.GuardOperatorURL's error with %w, and url.Parse's error echoes the URL it failed on — text that lands in both the audit reason and the API response.

The guard's reason is now surfaced without the value. A plain substring scrub is not sufficient: *url.Error renders as parse "<url>": <reason> with the URL quoted and Go-escaped, so the raw target never matches. The inner reason is unwrapped (it never carries the URL) and raw + escaped forms are scrubbed as belt-and-braces. The added test fails against the previous code.

Test plan

  • go build ./... clean
  • go vet ./internal/... clean
  • go test ./internal/plugin/ ./internal/server/ -race — both green
  • New: TestPluginInvokeRefusesNonDiagnosticActions (403 + artifact never runs for call/plan/execute/migrate; describe/health still 200)
  • New: TestPluginAssetCSPIgnoresRequestOrigin
  • New: TestOperatorTargetErrorRedactsSecret (fails against previous code)

Follow-up (not in this PR): the ownership decision — whether the three façade plugins' in-core-backed services become an explicit signed backing declaration. Tracked in the review doc.

https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78

Restrict /api/plugins/invoke to diagnostic actions.

Invoke is a raw runtime channel gated only by plugin:admin: it takes an
arbitrary action, consults no per-method scopes, and binds no operator target.
That was tolerable when it was the only way to reach a staged artifact, but
/api/plugins/call is now the real gateway and is the only path that enforces
the manifest's declared scopes, binds an operator target to a single
invocation, and (for host-risk work) requires a plan and an approval.

Left open, invoke would let a plugin:admin operator call a plugin's declared
methods while bypassing their scopes — and once any plugin ships an execute
action it would bypass the plan/approval/one-time-capability binding entirely
and apply host changes unreviewed. The action list is now closed to describe
and health; everything else is refused before the artifact runs.

Derive the plugin-asset CSP only from the configured public URL.

The policy fell back to an origin built from the request's Host header when
the public URL was unset, so the policy confining plugin code could be named
by the request it was confining. Asset serving now fails closed without a
public URL, and the request-derived fallback is gone.

Redact the operator target from guard failures.

An operator target may carry its secret in the URL path, and url.Parse errors
echo the URL they failed on — text that reaches both the audit record and the
API response, while the audit record must never contain it. The reason is now
surfaced without the value. A substring scrub is not enough on its own:
*url.Error renders the URL quoted and escaped, so the inner reason is unwrapped
and both raw and escaped forms are scrubbed.

Tests: go vet clean; internal/plugin and internal/server green under -race.
@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

[ack] — zeus review, Olympus TASK-0001 item 1 (2026-07-26)

All three fixes verified in diff and at the integration tip: closed diagnostic action list (403+audit before artifact run), CSP fail-closed without LATTICE_PUBLIC_URL (503; consistent with the WebAuthn RPID rule), operator-target redaction via url.Error unwrap + raw/escaped scrub. Composition with #12 verified: executePluginOperation's only-path property depends on this PR's closed list.

Evidence: this commit is already contained in integration (86422a1); full CI-mirror suite green there — gofmt/vet/docker-defaults clean, go test -race -cover ./... all ok (internal/server 397.6s @ 69.8%).

Deploy note: plugin assets now 503 without LATTICE_PUBLIC_URL — already true in prod since the passkey deploy; matters for fresh environments.

Disposition: landed via the integration cut → close-with-landing-commit per Olympus rules/01 §8.5 (do not merge to main directly). Verdict letter: lattice-olympus messages/inbox/hephaestus/20260726-0746Z-zeus-task0001-items1-4-verdicts.md.

@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): commit e920e4b is contained in integration (86422a1) via the alpha-line cut. [ack] verdict + evidence in the review comment above. Closing, not rejecting.

@lr00rl lr00rl closed this Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant