Merge pull request #1199 from J3vb/docs/plugin-critical-closure

feat(plugin): close audit plugin CRITICALs — per-command ACL, storage/event/HTTP dispositions
This commit is contained in:
J3vb
2026-07-20 16:25:09 +02:00
committed by GitHub
15 changed files with 404 additions and 30 deletions
+209
View File
@@ -0,0 +1,209 @@
// Audit 2026-04-07 plugin CRITICAL closure invariants.
//
// Each test here pins a property the closure table in
// docs/audit-2026-04-07.md cites as the reason a CRITICAL is closed. They live
// in the default-build file set on purpose so they run on every
// `go test ./...`, not only under -tags wazero.
package plugin
import (
"context"
"errors"
"strings"
"testing"
)
// TestRegisterCommandRequiresManifestDeclaration locks finding #3 (per-command
// ACL). Holding the `commands` capability is not enough: only names the
// manifest's `commands` block declares may bind, so a guest module cannot
// widen its own command surface via list_commands.
func TestRegisterCommandRequiresManifestDeclaration(t *testing.T) {
mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close(context.Background()) })
manifest, err := ParseManifest([]byte(
`{"name":"claimer","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"declared"}]}`))
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
inst := &Instance{ID: 1, Manifest: manifest}
if err := reg.RegisterCommand("declared", inst); err != nil {
t.Fatalf("declared command must bind: %v", err)
}
// "/DECLARED" normalizes to the declared name and must still bind.
if err := reg.RegisterCommand("/DECLARED", inst); err != nil {
t.Fatalf("normalized declared command must bind: %v", err)
}
for _, undeclared := range []string{"undeclared", "ban", "declared2"} {
if err := reg.RegisterCommand(undeclared, inst); !errors.Is(err, ErrCommandNotDeclared) {
t.Fatalf("RegisterCommand(%q) = %v, want ErrCommandNotDeclared", undeclared, err)
}
reg.mu.RLock()
_, bound := reg.commands[undeclared]
reg.mu.RUnlock()
if bound {
t.Fatalf("undeclared command %q must not be bound", undeclared)
}
}
}
func TestManifestCommandsValidation(t *testing.T) {
bad := map[string]string{
"no commands capability": `{"name":"x","version":"1","entrypoint":"x.wasm","commands":[{"name":"a"}]}`,
"uppercase name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"Ban"}]}`,
"leading slash": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"/ban"}]}`,
"empty name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":""}]}`,
"whitespace in name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a b"}]}`,
"duplicate name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a"},{"name":"a"}]}`,
}
for label, body := range bad {
t.Run(label, func(t *testing.T) {
if _, err := ParseManifest([]byte(body)); err == nil {
t.Fatalf("expected rejection for %s", label)
}
})
}
// Extra per-command fields from the richer schema in
// docs/plans/slash-commands.md must still parse (forward compatibility).
m, err := ParseManifest([]byte(
`{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"kick","description":"d","options":[]}]}`))
if err != nil {
t.Fatalf("rich command spec must parse: %v", err)
}
if !m.DeclaresCommand("kick") || m.DeclaresCommand("ban") {
t.Fatalf("DeclaresCommand wrong: %+v", m.Commands)
}
}
// TestStorageKeysIsolatedPerPlugin locks finding #2 (per-plugin key
// isolation). The namespace is the caller's Instance.ID, which no plugin-
// supplied input can influence, and plugin_kv's PRIMARY KEY (plugin_id, key)
// keeps identical keys from two plugins in separate rows.
func TestStorageKeysIsolatedPerPlugin(t *testing.T) {
ctx := context.Background()
mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Store: mem})
if err != nil {
t.Fatal(err)
}
newInst := func(name string) *Instance {
id, instErr := mem.InstallPlugin(ctx, name, "0.1", "{}")
if instErr != nil {
t.Fatalf("InstallPlugin(%s): %v", name, instErr)
}
return &Instance{
ID: id,
Manifest: &Manifest{Name: name, Permissions: []string{string(CapStorage)}},
}
}
alice, mallory := newInst("alice"), newInst("mallory")
if err := reg.StoragePut(ctx, alice, "secret", []byte("alice-value")); err != nil {
t.Fatalf("StoragePut alice: %v", err)
}
// Same key, different plugin: the writes must not collide.
if err := reg.StoragePut(ctx, mallory, "secret", []byte("mallory-value")); err != nil {
t.Fatalf("StoragePut mallory: %v", err)
}
got, err := reg.StorageGet(ctx, alice, "secret")
if err != nil || string(got) != "alice-value" {
t.Fatalf("alice's value was clobbered: got %q err %v", got, err)
}
// Scan is namespaced too: an empty prefix returns only the caller's keys.
all, err := reg.StorageScan(ctx, mallory, "", 0)
if err != nil {
t.Fatalf("StorageScan: %v", err)
}
if len(all) != 1 || string(all["secret"]) != "mallory-value" {
t.Fatalf("scan leaked across plugin namespaces: %v", all)
}
// Deleting from one namespace leaves the other intact.
if err := reg.StorageDelete(ctx, mallory, "secret"); err != nil {
t.Fatalf("StorageDelete: %v", err)
}
if got, err := reg.StorageGet(ctx, alice, "secret"); err != nil || string(got) != "alice-value" {
t.Fatalf("alice's key was deleted by mallory: got %q err %v", got, err)
}
}
func TestStorageRejectsOversizedKeyAndValue(t *testing.T) {
ctx := context.Background()
mem := openPluginTestDB(t)
reg, err := NewRegistry(Config{Store: mem})
if err != nil {
t.Fatal(err)
}
id, err := mem.InstallPlugin(ctx, "bloat", "0.1", "{}")
if err != nil {
t.Fatal(err)
}
inst := &Instance{ID: id, Manifest: &Manifest{Name: "bloat", Permissions: []string{string(CapStorage)}}}
if err := reg.StoragePut(ctx, inst, "", []byte("v")); err == nil {
t.Fatal("empty key must be rejected")
}
if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes+1), []byte("v")); err == nil {
t.Fatal("oversized key must be rejected")
}
if err := reg.StoragePut(ctx, inst, "k", make([]byte, maxPluginValueBytes+1)); err == nil {
t.Fatal("oversized value must be rejected")
}
if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes), make([]byte, maxPluginValueBytes)); err != nil {
t.Fatalf("at-limit key/value must be accepted: %v", err)
}
}
// TestEventDeliveryHasNoGuestPath locks finding #4. Dispatch is called on the
// hub's broadcast path (ws/hub.go) whenever plugins are enabled, but the
// finding (a plugin slowing the server by handling events slowly) is not
// reachable today: Subscribe requires the capability and has no production
// callers, and Dispatch invokes no guest code in either build. If this test
// has to change because Dispatch grew a real delivery path, that change must
// also bring the per-plugin rate limit — see the SECURITY GATE comment on
// EventSink.Dispatch.
func TestEventDeliveryHasNoGuestPath(t *testing.T) {
sink := NewEventSink()
noCap := &Instance{ID: 1, Manifest: &Manifest{Name: "nocap"}}
if err := sink.Subscribe("message_send", noCap); !errors.Is(err, ErrCapabilityNotGranted) {
t.Fatalf("Subscribe without the events capability = %v, want ErrCapabilityNotGranted", err)
}
sub := &Instance{ID: 2, Manifest: &Manifest{Name: "sub", Permissions: []string{string(CapEvents)}}}
if err := sink.Subscribe("message_send", sub); err != nil {
t.Fatalf("Subscribe: %v", err)
}
// inst.module is nil here, so a real guest call would panic or error.
sink.Dispatch(context.Background(), "message_send", []byte(`{}`))
sink.UnsubscribeAll(sub)
sink.mu.Lock()
remaining := len(sink.subs)
sink.mu.Unlock()
if remaining != 0 {
t.Fatalf("UnsubscribeAll left %d topics behind", remaining)
}
}
// TestEmptyAllowlistDeniesEveryHost locks the standing mitigation for finding
// #5 (HTTP exfiltration to allowlisted hosts): the shipped default
// `plugins.http_allowlist` is empty (config.DefaultConfig), and an empty
// allowlist must deny every destination rather than fall open.
func TestEmptyAllowlistDeniesEveryHost(t *testing.T) {
for _, allowlist := range [][]string{nil, {}, {"", " "}} {
reg := newTestRegistry(allowlist)
for _, host := range []string{"example.com", "api.github.com", "attacker.test", "localhost"} {
if reg.hostAllowed(host) {
t.Fatalf("allowlist %v must not permit %q", allowlist, host)
}
}
}
}
+6
View File
@@ -14,3 +14,9 @@ var ErrPluginNotFound = errors.New("plugin not found")
// ErrCapabilityNotGranted is returned when a host API call would require a
// capability the plugin's manifest did not declare.
var ErrCapabilityNotGranted = errors.New("plugin capability not granted")
// ErrCommandNotDeclared is returned when a plugin tries to bind a slash
// command its manifest did not list in `commands`. The manifest — not the
// guest module — is the authority on which commands a plugin may own, so an
// admin can see the full command surface before enabling the plugin.
var ErrCommandNotDeclared = errors.New("plugin command not declared in manifest")
+5
View File
@@ -8,6 +8,11 @@ Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`.
The manifest is the only file the default (no-`-tags wazero`) build needs —
the registry persists it into the plugins table without executing the .wasm.
The `commands` block is the per-command ACL: activation binds only the names
listed there, so `list_commands` returning anything else is ignored. Keep
`plugin.json`'s list and `listCommandsJSON` in `main.go` in sync — a name in
the WASM but not the manifest simply never binds.
## Building the WASM
`main.go` in this directory implements the full plugin ABI
+3
View File
@@ -5,6 +5,9 @@
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
"entrypoint": "hello.wasm",
"permissions": ["commands", "events", "storage"],
"commands": [
{ "name": "hello" }
],
"resources": {
"max_memory_mb": 16,
"cpu_budget_ms": 50
+8
View File
@@ -31,6 +31,14 @@ func (r *Registry) RegisterCommand(cmd string, inst *Instance) error {
if !inst.Manifest.HasCapability(CapCommands) {
return ErrCapabilityNotGranted
}
// Per-command ACL. The `commands` capability alone used to bind whatever
// the guest module returned from list_commands, so an admin enabling a
// plugin could not know which commands it would claim. The manifest is now
// the authority: only declared names bind, and this is the single choke
// point both auto-registration and direct registration route through.
if !inst.Manifest.DeclaresCommand(cmd) {
return fmt.Errorf("%w: %s/%s", ErrCommandNotDeclared, inst.Manifest.Name, cmd)
}
r.mu.Lock()
defer r.mu.Unlock()
// Ownership is compared by plugin identity (manifest name — unique per
+32 -2
View File
@@ -58,6 +58,11 @@ func (s *EventSink) Emit(channelID int64, payload []byte) {
// Subscribe binds inst to topic. Multiple plugins may subscribe to the same
// topic — events fan out to every subscriber.
//
// No production code calls Subscribe today (only this package's tests), so
// subs is always empty at runtime and Dispatch's loop never iterates. The
// first caller added here turns Dispatch's loop live on the hub's broadcast
// path — see the SECURITY GATE comment on Dispatch before adding one.
func (s *EventSink) Subscribe(topic string, inst *Instance) error {
if !inst.Manifest.HasCapability(CapEvents) {
return ErrCapabilityNotGranted
@@ -87,8 +92,33 @@ func (s *EventSink) UnsubscribeAll(inst *Instance) {
}
}
// Dispatch invokes every subscriber's on_event for topic. The default build
// is a no-op; the wazero-tagged build calls into the WASM module.
// Dispatch invokes every subscriber's on_event for topic.
//
// SECURITY GATE (audit 2026-04-07 finding #4 — "no rate limit on event
// delivery to plugins"). Read this before adding anything to the loop below.
//
// Dispatch already has a production caller: ws/hub.go calls it on every
// broadcast message when an operator has enabled plugins (api/router.go wires
// h.pluginSink whenever the registry is non-nil). That call site runs on the
// hub's broadcast goroutine while seqMu is held, so anything this function
// does is on the hub's hot path and must not block or re-enter the hub.
//
// Guest delivery is nonetheless NOT implemented in either build: the loop
// below touches no module, and no production code calls Subscribe (only this
// package's tests), so subs is empty and the loop never iterates. No guest
// code executes on the event path today — that, not an absent call site, is
// why a plugin cannot currently slow the hub by handling events slowly.
//
// Wiring guest delivery is what makes the finding real, so whoever does it
// must land, in the same change:
//
// - a per-plugin delivery rate limit (drop, never block the caller), and
// - the same per-call CPU-budget deadline invokeCommand applies
// (sandbox_wazero.go), and
// - delivery off the hub's broadcast goroutine so a slow guest cannot
// backpressure fan-out to WS clients or extend the seqMu hold.
//
// Until then this stays inert on purpose.
func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) {
s.mu.Lock()
subs := append([]*Instance(nil), s.subs[topic]...)
+13
View File
@@ -11,7 +11,14 @@ import (
"fmt"
)
// Key isolation is structural rather than checked: every call below passes
// inst.ID as the namespace and there is no parameter by which a caller (let
// alone a guest module) can name a different plugin's namespace. The
// plugin_kv table's PRIMARY KEY (plugin_id, key) — migrations/015_plugins.sql
// — makes the same split the storage layout, so two plugins using the same
// key never collide. Audit 2026-04-07 finding #2.
const (
maxPluginKeyBytes = 256 // 256 B per key
maxPluginValueBytes = 64 * 1024 // 64 KB per value
maxPluginScanLimit = 1000 // hard cap on PluginKVScan results
)
@@ -21,6 +28,12 @@ func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, v
if !inst.Manifest.HasCapability(CapStorage) {
return ErrCapabilityNotGranted
}
if key == "" {
return fmt.Errorf("plugin storage: key must not be empty")
}
if len(key) > maxPluginKeyBytes {
return fmt.Errorf("plugin storage: key exceeds %d bytes", maxPluginKeyBytes)
}
if len(value) > maxPluginValueBytes {
return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes)
}
+69 -8
View File
@@ -36,18 +36,39 @@ import (
// with a letter or digit.
var pluginNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// pluginCommandRegexp restricts declared slash-command names to the same
// lowercase charset the dispatcher normalizes to (RegisterCommand lowercases
// and strips a leading "/"), so a declaration always compares equal to the
// name a client can actually invoke.
var pluginCommandRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
// maxManifestCommands caps how many commands one plugin may claim.
const maxManifestCommands = 64
// Manifest is the parsed plugin metadata declared in plugin.json (or
// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally
// flat so the default JSON parser handles it without a TOML dependency.
type Manifest struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Entrypoint string `json:"entrypoint"` // relative .wasm path
Permissions []string `json:"permissions"`
Resources Resources `json:"resources"`
UI UISpec `json:"ui"`
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Entrypoint string `json:"entrypoint"` // relative .wasm path
Permissions []string `json:"permissions"`
Commands []CommandSpec `json:"commands"`
Resources Resources `json:"resources"`
UI UISpec `json:"ui"`
}
// CommandSpec is one slash command the plugin declares. The declaration is
// the per-command ACL: activation only binds names listed here, so a guest
// module cannot claim commands its manifest never advertised.
//
// Only Name is enforced today. docs/plans/slash-commands.md extends this
// object with description/options/permission fields later; unknown JSON keys
// are ignored, so manifests written against that richer schema still parse.
type CommandSpec struct {
Name string `json:"name"`
}
// Resources caps the plugin's runtime budget. Zero means "use the runtime
@@ -130,6 +151,9 @@ func (m *Manifest) Validate() error {
return fmt.Errorf("plugin manifest: unknown permission %q", p)
}
}
if err := m.validateCommands(); err != nil {
return err
}
if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 {
return fmt.Errorf("plugin manifest: resources must be non-negative")
}
@@ -180,6 +204,43 @@ func validateRelativePath(p string) error {
return nil
}
// validateCommands enforces the per-command ACL schema: declaring commands
// requires the `commands` capability, names must be canonical (the form the
// dispatcher normalizes to), and the list is bounded and duplicate-free.
func (m *Manifest) validateCommands() error {
if len(m.Commands) == 0 {
return nil
}
if !m.HasCapability(CapCommands) {
return fmt.Errorf("plugin manifest: commands declared without the %q permission", CapCommands)
}
if len(m.Commands) > maxManifestCommands {
return fmt.Errorf("plugin manifest: too many commands (%d, max %d)", len(m.Commands), maxManifestCommands)
}
seen := make(map[string]bool, len(m.Commands))
for i, c := range m.Commands {
if !pluginCommandRegexp.MatchString(c.Name) {
return fmt.Errorf("plugin manifest: commands[%d].name %q must match %s", i, c.Name, pluginCommandRegexp.String())
}
if seen[c.Name] {
return fmt.Errorf("plugin manifest: duplicate command %q", c.Name)
}
seen[c.Name] = true
}
return nil
}
// DeclaresCommand reports whether the manifest listed cmd in its `commands`
// block. cmd is expected in normalized form (lowercase, no leading "/").
func (m *Manifest) DeclaresCommand(cmd string) bool {
for _, c := range m.Commands {
if c.Name == cmd {
return true
}
}
return false
}
// HasCapability reports whether the manifest declared cap.
func (m *Manifest) HasCapability(cap Capability) bool {
for _, p := range m.Permissions {
+3 -3
View File
@@ -158,7 +158,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
t.Cleanup(func() { _ = reg.Close(context.Background()) })
ctx := context.Background()
manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"]}`))
manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
@@ -173,7 +173,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
}
// In-place upgrade: same plugin name, fresh instance.
manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"]}`))
manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
if err != nil {
t.Fatalf("ParseManifest v2: %v", err)
}
@@ -197,7 +197,7 @@ func TestReinstallRebindsCommands(t *testing.T) {
}
// A different plugin still cannot hijack an owned command.
other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"]}`))
other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`))
if err != nil {
t.Fatalf("ParseManifest other: %v", err)
}
+4 -1
View File
@@ -131,7 +131,10 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *
// the cross-plugin command-hijack hole. RegisterCommand acquires r.mu
// itself, so it is called outside the lock below to avoid re-entrant
// locking. The plugin must also have declared the `commands` capability in
// its manifest, otherwise no binding happens.
// its manifest, otherwise no binding happens — and RegisterCommand refuses
// any name the manifest's `commands` block did not declare, so a guest
// module cannot widen its own command surface by returning extra names
// from list_commands.
r.mu.Lock()
if inst.module != nil {
// Lost a concurrent activation race (e.g. two dispatches both saw a
+5 -5
View File
@@ -81,7 +81,7 @@ func TestWazeroRegistryCreatesRuntime(t *testing.T) {
func TestWazeroActivateCompilesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
@@ -116,7 +116,7 @@ func TestWazeroActivateCompilesModule(t *testing.T) {
func TestWazeroDispatchCommandMissingExport(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
@@ -147,7 +147,7 @@ func TestWazeroDispatchCommandMissingExport(t *testing.T) {
func TestWazeroCloseTearsDownRuntime(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
@@ -174,7 +174,7 @@ func TestWazeroCloseTearsDownRuntime(t *testing.T) {
func TestWazeroDisablePluginFreesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
@@ -270,7 +270,7 @@ var spinWASM = []byte{
// admin disable/enable cycle or server restart.
func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"spin"}]}`
writeTestPlugin(t, dir, "spinner", manifest, spinWASM)
reg, mem := newWazeroTestRegistry(t, dir)
+35 -8
View File
@@ -5,20 +5,40 @@
---
## Finding closure status (maintained; last updated 2026-07-18)
## Finding closure status (maintained; last updated 2026-07-20)
Every CRITICAL/HIGH below must end with a closing commit link or an explicit
mitigation before the beta gate. Standing rule: any plugin CRITICAL still
OPEN at the beta gate → plugins ship default-disabled (they already default
to `plugins.enabled: false`).
OPEN at the beta gate → plugins ship default-disabled.
**Rule status 2026-07-20:** finding #5 is closed as *accepted residual risk*,
not fixed, so the rule fires — plugins ship default-disabled at beta.
Verified in code: `config.DefaultConfig()` sets `Plugins.Enabled: false` and
`Plugins.HTTPAllowlist: []string{}` (`Server/config/config.go:206-212`), and
`hostAllowed` denies every host against an empty allowlist
(`Server/plugin/host_http.go:140-158`, pinned by
`TestEmptyAllowlistDeniesEveryHost`).
**Structural mitigation covering #2, #4 and #5:** no host imports are wired
into the wazero runtime. `activateWithRuntime` instantiates guest modules with
WASI preview-1 only (`Server/plugin/sandbox_wazero.go:69-124`), and
`HTTPDo` / `Storage*` have no callers outside the `plugin` package's own
tests. `EventSink.Dispatch` is the exception — `Server/ws/hub.go:1034` calls
it on every broadcast when plugins are enabled — but its loop body is inert
and nothing outside tests calls `EventSink.Subscribe`, so it iterates over an
empty subscriber set and reaches no guest code (see #4). The only
guest-reachable entry points today are `command_dispatch` (via the WS
`chat_command` handler) and `list_commands` at activation. Wiring those host
imports is what makes #5 exploitable at all and is the point at which #4's
rate limit must exist.
| # | Sev | Finding | Status |
|---|-----|---------|--------|
| 1 | CRITICAL | Plugin `invokeCommand` has no timeout | IN PROGRESS — CPU budget added on `fix/security-hardening-review`; regression fix (module bricking, W1-1) required before merge |
| 2 | CRITICAL | Plugin storage has no per-plugin key isolation | OPEN — verify/close in P3 |
| 3 | CRITICAL | Plugin per-command ACL missing (auto-registration) | OPEN — verify/close in P3 |
| 4 | CRITICAL | No rate limit on event delivery to plugins | OPEN — verify/close in P3 |
| 5 | CRITICAL | Plugin HTTP capability allows data exfiltration to allowlisted hosts | OPEN — partially mitigated by SSRF hardening + allowlist; document residual risk in P3 |
| 1 | CRITICAL | Plugin `invokeCommand` has no timeout | **CLOSED 2026-07-20** — verified in code. Every guest call (`allocate` / `command_dispatch` / `deallocate`) runs under a per-invocation deadline: `budgetMs` = manifest `resources.cpu_budget_ms``plugins.cpu_budget_ms` → hard 100 ms floor, applied via `context.WithTimeout` (`Server/plugin/sandbox_wazero.go:257-268`). The runtime is built `WithCloseOnContextDone(true)` (`sandbox_wazero.go:69-73`) so an expired deadline interrupts a runaway guest (`for {}`), and `releaseClosedModule` (`sandbox_wazero.go:326-338`) drops the closed module so the next dispatch re-instantiates lazily instead of bricking the plugin (regression W1-1). Landed in PR #1182 (`0f58ddd` budget, `2111976` W1-1). Pinned by `TestWazeroCPUBudgetOverrunDoesNotBrickPlugin` (`sandbox_wazero_test.go:271`) |
| 2 | CRITICAL | Plugin storage has no per-plugin key isolation | **CLOSED 2026-07-20** — the finding's premise does not hold against the code. Isolation is structural, not a check that can be skipped: every `Storage*` call passes the caller's `Instance.ID` as the namespace and exposes no parameter by which a caller — let alone a guest module — could name another plugin's namespace (`Server/plugin/host_storage.go:26-73`), and `plugin_kv PRIMARY KEY (plugin_id, key)` (`Server/migrations/015_plugins.sql:13-18`) makes the same split the storage layout. Every query filters on `plugin_id` (`Server/db/plugin_queries.go:87-135`). This PR adds `TestStorageKeysIsolatedPerPlugin` pinning it (same key from two plugins does not collide; scan/delete do not cross namespaces) plus the missing key-size cap the file's doc comment already promised |
| 3 | CRITICAL | Plugin per-command ACL missing (auto-registration) | **CLOSED 2026-07-20 (this PR)** — the manifest is now the per-command ACL. `plugin.json` gains a `commands` block; `RegisterCommand` refuses any name the manifest did not declare (`Server/plugin/host_commands.go:31-45`, `ErrCommandNotDeclared`), which is the single choke point both `list_commands` auto-registration (`sandbox_wazero.go:146-153`) and direct registration route through. A guest can therefore no longer widen its own command surface, and an admin can see the full command list before enabling. Declared names are validated to the dispatcher's canonical form, deduplicated, and capped at 64 (`manifest.go:207-233`). Cross-plugin hijack was already refused and stays refused. Pinned by `TestRegisterCommandRequiresManifestDeclaration` + `TestManifestCommandsValidation` |
| 4 | CRITICAL | No rate limit on event delivery to plugins | **CLOSED 2026-07-20 (no guest code on the event path)** — there is no guest delivery to rate-limit. Note what *is* wired, so this is not mistaken for an absent call site: `EventSink.Dispatch` has exactly one caller outside the `plugin` package's tests — `Server/ws/hub.go:1034`, invoked on **every** broadcast message whenever an operator enables plugins (`Server/api/router.go:134-139` sets `h.pluginSink` when the registry is non-nil), on the hub's broadcast goroutine while `seqMu` is held. What makes the finding unreachable is one level down: `Dispatch`'s loop body invokes no guest code in either build (it touches no `inst.module`), and no production code calls `EventSink.Subscribe` — only tests — so `subs` is empty and the loop never iterates. A plugin cannot slow the hub by handling events slowly because no plugin ever handles one. Recorded as a gate rather than left silent: the SECURITY GATE comment on `Server/plugin/host_events.go` requires the per-plugin rate limit, the `invokeCommand` CPU deadline, and off-hub-goroutine delivery to land *in the same change* that wires guest delivery — and flags that the hot call site already exists, so wiring is a one-line `Subscribe` away, not a new integration. `TestEventDeliveryHasNoGuestPath` fails if delivery appears without that review |
| 5 | CRITICAL | Plugin HTTP capability allows data exfiltration to allowlisted hosts | **OPEN — accepted residual risk (2026-07-20)**. Not fixable by hardening: an allowlisted host is by definition a permitted destination, so a plugin holding `http` can POST anything it can read to it. Closing it properly needs egress content policy (per-plugin request/response body inspection, byte budgets, per-plugin allowlists instead of one server-wide list) — a plugin-runtime redesign, not a patch. Standing mitigations, all verified in code: (a) `plugins.enabled` defaults false; (b) `plugins.http_allowlist` defaults empty and an empty allowlist denies every host, so the capability is inert until an operator names a destination; (c) the manifest must declare `http`, which is visible to the admin before enabling; (d) no host import is wired, so guest code cannot call `HTTPDo` at all today; (e) SSRF hardening (allowlist dot-boundary matching, guarded dial that vets every resolved IP before connecting, redirect re-checks, 5 MiB response cap) confines reach to public allowlisted hosts. Residual risk accepted for alpha/beta: an operator who both enables plugins and allowlists a host trusts the plugins they install with data those plugins can read |
| 6 | HIGH | `Server/store/` untested | SUPERSEDED — `store/` package is being removed in P4 (single data layer); tests move to in-memory SQLite |
| 7 | HIGH | Client `src/lib`/`src/stores` <10% unit coverage | CLOSED since audit — large vitest suite exists (113 files); suite health tracked in P2 |
| 8 | HIGH | Unpinned critical npm packages | OPEN — review in P2 |
@@ -373,6 +393,13 @@ Auth flow, channels, messages, DMs, health/reconnect, UI overlays, voice control
### CRITICAL Issues
> **Closure status (2026-07-20):** the table below is the original 2026-04-07
> record and is kept verbatim. Current state lives in the closure table at the
> top of this document — findings 14 are closed; #5 (HTTP exfiltration to an
> allowlisted host) is accepted residual risk, which keeps plugins
> default-disabled at the beta gate. Line numbers below refer to the audited
> tree, not today's.
| SEVERITY | File:Line | Finding |
|----------|-----------|---------|
| **CRITICAL** | `Server/plugin/sandbox_wazero.go:162,211` | `invokeCommand` has **no timeout** — a looping plugin hangs the goroutine indefinitely |
+1 -1
View File
@@ -49,7 +49,7 @@ audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md).
| Prior # | Sev | Finding (one-line) | Re-verification (2026-07-19) |
|---------|-----|--------------------|------------------------------|
| 15 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | Unchanged since prior closure table; plugins still default-disabled (`plugins.enabled: false`), which is the standing mitigation |
| 15 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | **Re-verified 2026-07-20 (P3)** — 14 closed, #5 accepted residual risk; full per-finding detail (with file:line and pinning tests) stays in [audit-2026-04-07.md](audit-2026-04-07.md). #1 closed by the CPU budget + lazy re-instantiation in PR #1182; #2 closed as structural — the KV namespace is the caller's `Instance.ID` and `plugin_kv PRIMARY KEY (plugin_id, key)`, no parameter can name another plugin's namespace; #3 closed by a new manifest `commands` ACL that `RegisterCommand` enforces, so `list_commands` can no longer bind undeclared names; #4 closed because no guest code runs on the event path — `EventSink.Dispatch` *is* called from `Server/ws/hub.go:1034` on every broadcast when plugins are enabled, but its loop body invokes no guest code and no production code calls `EventSink.Subscribe`, so it iterates an empty subscriber set; a SECURITY GATE comment on `Dispatch` requires the rate limit, CPU deadline and off-hub-goroutine delivery in whatever change wires real delivery, and warns that the hot call site already exists; #5 is not fixable by hardening (an allowlisted host *is* a permitted destination) and is accepted for alpha/beta. Standing mitigation unchanged and re-verified: `plugins.enabled: false` and an empty `plugins.http_allowlist` by default, plus no host imports wired into the wazero runtime |
| 6 | HIGH | `Server/store/` untested | **RESOLVED 2026-07-19 (D3)** — the `store/` package is deleted rather than tested. `SQLiteStore` was a pure pass-through to `*db.DB`; its event/plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`). Consumers now depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`), and the former `MemStore`-based unit tests run against a real in-memory SQLite `db` — so the code paths that were untested through the seam are now exercised directly |
| 7 | HIGH | Client unit coverage | Suite is large (157 test files) and green; flipping `client-tests` to blocking is backlog #10 — see A-2026-07-04 |
| 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open**`Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` |
+3 -2
View File
@@ -1,8 +1,8 @@
# Audit 2026-07-19 — Maintainer Decisions
**Date decided:** 2026-07-19 (D1D8); 2026-07-20 (D9D10)
**Date decided:** 2026-07-19 (D1D8); 2026-07-20 (D9D11)
**Decided by:** J3vb
**Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status. **2026-07-20:** backlog items 3 and 11 (D9, D10) implemented — channel-visibility unified through `permissions.Checker`; V2 dispatch migration finished and V1 deleted.
**Status:** decisions recorded; greenlit items (D4, D7, D8) implemented 2026-07-19 — see per-row Status. **2026-07-20:** backlog items 3 and 11 (D9, D10) implemented — channel-visibility unified through `permissions.Checker`; V2 dispatch migration finished and V1 deleted. **2026-07-20 (P3):** the five plugin CRITICALs carried over from audit-2026-04-07 dispositioned (D11) — four closed, one accepted as residual risk, which keeps plugins default-disabled at the beta gate.
**Source:** decision points raised by [docs/audit-2026-07-19.md](../audit-2026-07-19.md)
This document records the maintainer's answers to the open decision points from
@@ -24,6 +24,7 @@ here (and the audit's closure table) as items land.
| D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch**`LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. |
| D9 | Channel-visibility unification (rule duplicated across ~4 "must mirror" sites) | A-2026-07-07 / backlog 3 | **Greenlit 2026-07-20 — implement**: funnel all four sites through the existing `permissions.Checker` predicate + one filter helper; add a REST/WS agreement test. See [channel-visibility-unification.md](channel-visibility-unification.md). | **Implemented 2026-07-20**`permissions.Checker.VisibleChannelIDs` + `ChannelRef`; `ListVisibleChannels`, `buildReady`, `computeAllowedChannels` delegate; `RefreshChannelVisibility` uses `HasChannelPerm`. REST/WS agreement test asserts all three sites yield the identical non-DM set. |
| D10 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 / backlog 11 | **Greenlit 2026-07-20 — implement**: port the 3 remaining V1 types (`chat_command`, `voice_join`, `voice_leave`) to V2, then delete the V1 registry + fallback path. Server-internal only, no wire change. See [v2-dispatch-migration.md](v2-dispatch-migration.md). | **Implemented 2026-07-20** — the 3 types ported to typed V2 handlers (voice join/leave hand off to the hub routines via new `Result.JoinVoice`/`LeaveVoice` appliers); V1 registry + `handleMessage` fallback deleted; a constructor↔handler parity guard test locks it shut. No wire change. |
| D11 | Disposition of the five plugin CRITICALs from audit-2026-04-07 (§1 carried-over row) | prior #1#5 | **Close what the code already closes; fix the one cheap real gap; accept the one that hardening cannot fix.** Verified each against `Server/plugin/` rather than the tracker: #1 (no `invokeCommand` timeout) closed by PR #1182 — per-call CPU budget with a 100 ms floor plus `WithCloseOnContextDone` and lazy re-instantiation so an overrun does not brick the plugin. #2 (storage key isolation) closed as structural — the namespace is the caller's `Instance.ID` and `plugin_kv PRIMARY KEY (plugin_id, key)`; no parameter exists by which a plugin could name another's namespace, so the finding's premise was wrong. #3 (per-command ACL) was a **real gap** and is fixed here: the manifest gains a `commands` block and `RegisterCommand` refuses undeclared names, so `list_commands` can no longer widen a plugin's command surface behind the admin's back. #4 (event rate limit) closed because no guest code executes on the event path — precisely: `EventSink.Dispatch` has exactly one caller outside the plugin package's tests (`Server/ws/hub.go:1034`, on every broadcast when plugins are enabled, on the hub goroutine under `seqMu`), but its loop body invokes no guest code and no production code calls `EventSink.Subscribe`, so the subscriber set is always empty. Rather than build a limiter for guest calls that do not happen, the requirement is recorded as a SECURITY GATE comment on `Dispatch` and `Subscribe` — the exact places someone would wire delivery — including the warning that the hot call site already exists and sits under the hub's `seqMu`. #5 (HTTP exfiltration to an allowlisted host) **stays open as accepted residual risk** — an allowlisted host is by definition permitted, so closing it needs egress content policy and per-plugin allowlists (a runtime redesign, ~12 weeks), explicitly out of scope for P3. | **Implemented 2026-07-20** — closure tables in [audit-2026-04-07.md](../audit-2026-04-07.md) and the §1 row of [audit-2026-07-19.md](../audit-2026-07-19.md) updated; manifest `commands` ACL + key-size cap + five pinning tests landed (`Server/plugin/audit_closure_test.go`). Because #5 remains open, the standing rule fires as written: **plugins ship default-disabled at the beta gate** — re-verified in `config.DefaultConfig()` (`Plugins.Enabled: false`, empty `HTTPAllowlist`). |
## Suggested sequencing
+8
View File
@@ -109,6 +109,14 @@ Plugin manifests gain a `commands` block. The manifest is the source of
truth for the per-command schema; the runtime never trusts what the plugin
says at dispatch time. Example:
> **Partially landed 2026-07-20** (audit-2026-04-07 CRITICAL #3): the
> *name-only* slice of this block exists today — `plugin.json` accepts
> `"commands": [{"name": "kick"}]` and `Registry.RegisterCommand` refuses any
> command the manifest did not declare, so `list_commands` can no longer bind
> names behind the admin's back. `description` / `options` /
> `default_member_permissions` below are still design-only; unknown keys parse
> and are ignored, so manifests written against the full schema already load.
```json
{
"name": "moderation-tools",