diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index 4decc103..eaa6da52 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -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 } diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 05bd60ec..0c01e1c1 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -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"]}`