mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user