Skip to content

Namespace plugin-emitted hook events to plugin.<id>.*#6

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/namespace-plugin-hook-events
Jun 10, 2026
Merged

Namespace plugin-emitted hook events to plugin.<id>.*#6
DavidBabinec merged 1 commit into
mainfrom
fix/namespace-plugin-hook-events

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Summary

Security fix: any plugin granted cms.hooks could forge core/system events. handleHooksEmit (server/plugins/host/handlers/hooks.ts) passed the plugin-supplied event name straight to hookBus.emit, so plugin A could fire a fake content.entry.created at plugin B's listeners or spoof settings.changed / publish.before / publish.after.

Plugin-emitted events are now force-namespaced to plugin.<pluginId>.<name> by the host, making event provenance explicit and unforgeable. The contract (one coherent rule — auto-prefix, never spoof):

  • a bare name (sync.done) is delivered as plugin.<id>.sync.done — including reserved core names, so a raw emit of content.entry.created becomes the harmless plugin.<id>.content.entry.created
  • a name already in the plugin's own namespace passes through unchanged (no double prefix)
  • a name in another plugin's namespace (plugin.<other-id>.*) is rejected with a clear impersonation error
  • subscriptions (cms.hooks.on) stay unrestricted — cross-plugin eventing works by listening to the full namespaced name
  • cms.hooks.emit now resolves to the canonical namespaced name

Changes

  • server/plugins/host/handlers/hooks.ts — kernel of correctness: handleHooksEmit canonicalizes via the worker-verified msg.pluginId (never plugin-supplied data) and replies with the canonical name
  • src/core/plugins/hookBus.tscanonicalPluginEventName() + CORE_HOOK_EVENTS as the single source of truth for reserved host events (settings.changed, content.entry.created/updated/deleted, publish.before/after); HookBus.emit is typed CoreHookEvent | PluginScopedHookEvent, so a host emit site cannot introduce a core event without adding it to the list (compile-time anti-drift)
  • src/core/plugin-sdk/types/hooks.tsServerPluginHooksApi.emit documents the namespacing and returns Promise<string> (the canonical name); fixed the cross-plugin naming example
  • server/plugins/quickjs/bootstrap/src/buildApi.ts — VM-side contract comment; bun run bootstrap:sync ran (generated artifacts byte-identical — comments are stripped by bundling; plugin-bootstrap-fresh.test.ts green)
  • docs/features/plugin-system.md — namespace rule, reserved host-event list, cross-plugin listening; removed the phantom media.uploaded event (documented but never emitted)
  • Editor-side plugin API exposes no hook emit (src/core/plugins/runtime.ts / events.ts are unrelated surfaces) — nothing to change there
  • examples/plugins/template/ does not use hooks.emit — unchanged

Tests

  • src/__tests__/plugins/pluginHookBus.test.ts — unit coverage of all four canonicalization rules + host core emits still reaching bare-name listeners
  • src/__tests__/server/pluginServerRuntime.test.ts — new end-to-end test through real plugin install/activate: bare emit lands namespaced; forged content.entry.created never reaches core-name listeners; self-prefixed emit not double-prefixed; other-namespace emit rejected with a clear error; emit resolves to the canonical name
  • Updated existing tests that listened on raw plugin-emitted names (cmsPlugins.test.ts, pluginServerRuntime.test.ts, pluginVmPermissions.test.ts)

Verification

  • bun test — 4915 pass, 0 fail
  • bun run build (tsc -b && vite build) — clean
  • bun run lint — clean

🤖 Generated with Claude Code

Any plugin granted cms.hooks could forge core/system events: handleHooksEmit
passed the plugin-supplied event name straight to hookBus.emit, so plugin A
could fire a fake content.entry.created at plugin B's listeners or spoof
settings.changed / publish.* events.

Plugin emits are now force-namespaced by the host (the kernel of correctness
is handleHooksEmit, keyed on the worker-verified msg.pluginId, never
plugin-supplied data):

- canonicalPluginEventName (src/core/plugins/hookBus.ts): bare names are
  prefixed to plugin.<id>.<name> (a raw emit of content.entry.created becomes
  the harmless plugin.<id>.content.entry.created), names already in the
  plugin's own namespace pass through unchanged, and names in another
  plugin's namespace are rejected as impersonation.
- CORE_HOOK_EVENTS is the single source of truth for the reserved
  host-emitted names; hookBus.emit is typed CoreHookEvent |
  PluginScopedHookEvent so host emit sites cannot drift from the list.
- cms.hooks.emit now resolves to the canonical namespaced name so plugin
  authors can log/share the exact name other plugins must subscribe to.
  Subscriptions (cms.hooks.on) stay unrestricted — cross-plugin eventing
  works by listening to the full namespaced name.
- SDK types (ServerPluginHooksApi.emit) and the VM bootstrap document the
  contract; docs/features/plugin-system.md gains the namespace rule + the
  reserved event list and drops the phantom media.uploaded event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DavidBabinec DavidBabinec force-pushed the fix/namespace-plugin-hook-events branch from cbb3ea5 to d20c602 Compare June 10, 2026 11:30
@DavidBabinec DavidBabinec merged commit 9ca73b2 into main Jun 10, 2026
5 checks passed
@DavidBabinec DavidBabinec deleted the fix/namespace-plugin-hook-events branch June 10, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant