fix(plugin): guard against wasm guests with no memory section (F2)

The guest's linear memory was taken from mod.Memory() and used unchecked, so
an untrusted plugin wasm with no memory section nil-dereferenced on the
unrecovered startup path and crashed the server. All guest-memory access now
goes through one guestMemory() helper that detects wazero's non-nil interface
wrapping a nil *MemoryInstance, binding no commands at activation and
returning the existing missing-export diagnostic on dispatch.

Verified by a panel of agents; the added regression test panics with the
finding's exact stack against the unpatched tree.

Note: TestRegistry_Activate_WithoutRuntime and
TestRegistry_EnablePlugin_RollsBackWhenActivationFails fail under
-tags wazero, confirmed here to fail identically on the base tree. They are
pre-existing and unrelated; CI builds the wazero variant but does not test it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-30 15:00:26 +02:00
co-authored by Claude Opus 5
parent 77121adcaa
commit 6258681731
2 changed files with 117 additions and 4 deletions
+39 -4
View File
@@ -38,6 +38,7 @@ import (
"io"
"log/slog"
"os"
"reflect"
"strings"
"time"
@@ -49,6 +50,25 @@ import (
// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB).
const wazeroPageBytes = 65536
// guestMemory returns the module's linear memory, or nil when the guest
// declared none. It exists because api.Module.Memory() is NOT safe to
// dereference blindly: wazero returns its *wasm.MemoryInstance field as-is, and
// that field is only populated for a module with a memory section — so a
// memoryless guest yields a non-nil api.Memory interface wrapping a nil
// pointer, and every method on it (Read, Write, Size) panics. A plain
// `mem == nil` check does not catch that, hence the pointer-level check here.
// Every host access to guest memory must go through this helper.
func guestMemory(mod api.Module) api.Memory {
mem := mod.Memory()
if mem == nil {
return nil
}
if v := reflect.ValueOf(mem); v.Kind() == reflect.Ptr && v.IsNil() {
return nil
}
return mem
}
// platformInit stands up the shared wazero runtime for this Registry. The
// runtime is the top-level handle that owns compiled modules, host modules,
// and per-instance linear memory; every plugin in this registry shares it.
@@ -237,6 +257,15 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
Reply: fmt.Sprintf("plugin %s: missing allocate export (required for command dispatch)", inst.Manifest.Name),
}, true
}
// A guest that declares no linear memory has no usable JSON ABI. Checked
// here, with the other ABI preconditions and before any guest call, rather
// than dereferenced blindly further down.
mem := guestMemory(mod)
if mem == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %s: no exported memory (required for command dispatch)", inst.Manifest.Name),
}, true
}
type dispatchPayload struct {
UserID int64 `json:"user_id"`
@@ -287,7 +316,6 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
}
ptr := ptrs[0]
mem := mod.Memory()
if !mem.Write(uint32(ptr), payload) {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true
}
@@ -350,8 +378,9 @@ func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) {
// listExportedCommands calls the plugin's optional `list_commands` export
// which returns (ptr u32, len u32) pointing to a JSON array of command name
// strings. If the export is absent or returns invalid JSON, an empty slice
// is returned and no command bindings are created.
// strings. If the export is absent, the module declares no linear memory, or
// the result is invalid JSON, an empty slice is returned and no command
// bindings are created.
func listExportedCommands(ctx context.Context, mod api.Module) []string {
fn := mod.ExportedFunction("list_commands")
if fn == nil {
@@ -361,8 +390,14 @@ func listExportedCommands(ctx context.Context, mod api.Module) []string {
if err != nil || len(results) < 2 {
return nil
}
// A guest with no memory section has no memory to read the name list from
// (and its api.Memory must not be touched — see guestMemory).
mem := guestMemory(mod)
if mem == nil {
return nil
}
ptr, length := uint32(results[0]), uint32(results[1])
raw, ok := mod.Memory().Read(ptr, length)
raw, ok := mem.Read(ptr, length)
if !ok {
return nil
}
+78
View File
@@ -367,6 +367,84 @@ func TestWazeroConcurrentDispatchRace(t *testing.T) {
wg.Wait()
}
// noMemWASM exports the command ABI but declares NO memory section, so
// api.Module.Memory() returns nil for it:
//
// (module
// (func (export "list_commands") (result i32 i32) i32.const 0 i32.const 0)
// (func (export "allocate") (param i32) (result i32) i32.const 0)
// (func (export "command_dispatch") (param i32 i32) (result i32 i32)
// i32.const 0 i32.const 0))
var noMemWASM = []byte{
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // \0asm v1
// type section: ()->(i32,i32), (i32)->i32, (i32,i32)->(i32,i32)
0x01, 0x12, 0x03,
0x60, 0x00, 0x02, 0x7f, 0x7f,
0x60, 0x01, 0x7f, 0x01, 0x7f,
0x60, 0x02, 0x7f, 0x7f, 0x02, 0x7f, 0x7f,
// function section: 3 funcs using types 0,1,2
0x03, 0x04, 0x03, 0x00, 0x01, 0x02,
// (no memory section — this is the point of the fixture)
// export section: list_commands, allocate, command_dispatch
0x07, 0x2f, 0x03,
0x0d, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x00, 0x00,
0x08, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x01,
0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x00, 0x02,
// code section
0x0a, 0x14, 0x03,
0x06, 0x00, 0x41, 0x00, 0x41, 0x00, 0x0b, // list_commands: (0,0)
0x04, 0x00, 0x41, 0x00, 0x0b, // allocate: 0
0x06, 0x00, 0x41, 0x00, 0x41, 0x00, 0x0b, // command_dispatch: (0,0)
}
// TestWazeroMemorylessModuleDoesNotPanic locks the nil-memory guard: a guest
// that exports the command ABI but declares no memory section must be handled
// as a bad plugin, not dereferenced. Both host paths that touch guest memory
// are covered — activation (list_commands) and dispatch (command_dispatch) —
// because a panic on the activation path aborts server startup for every
// subsequent restart while the plugin row stays enabled.
func TestWazeroMemorylessModuleDoesNotPanic(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"nomem","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"noop"}]}`
writeTestPlugin(t, dir, "nomem", manifest, noMemWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
// Activation calls list_commands, which used to nil-deref the guest memory.
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if inst == nil || inst.module == nil {
t.Fatal("expected the module to activate")
}
// No command may bind: the name list is unreadable without memory.
reg.mu.RLock()
_, bound := reg.commands["noop"]
reg.mu.RUnlock()
if bound {
t.Fatal("memoryless module must not auto-bind commands")
}
// The dispatch path must report a diagnostic instead of writing into nil.
if err := reg.RegisterCommand("noop", inst); err != nil {
t.Fatalf("RegisterCommand: %v", err)
}
result, ok := reg.DispatchCommand(ctx, 1, 2, "noop", nil)
if !ok || result == nil {
t.Fatalf("expected a dispatch result: ok=%v result=%+v", ok, result)
}
if !strings.Contains(result.Reply, "no exported memory") {
t.Fatalf("expected a missing-memory diagnostic, got %q", result.Reply)
}
}
func TestWazeroInvalidWASMFailsActivation(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`