diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5258dd1c..16e23f69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,21 @@ jobs: - name: Build server run: go build -o ${{ matrix.binary }} -ldflags "-s -w" . + # Phase B + C build-tag matrix. Each tag variant must compile so the + # tag boundaries don't drift. The OTel and wazero tags are gated + # behind `continue-on-error: true` until the upstream modules land in + # go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once the modules are + # added, drop continue-on-error so a missing tag combo fails CI. + - name: Build with -tags otel (Phase B Step 8) + continue-on-error: true + run: go build -tags otel ./... + - name: Build with -tags wazero (Phase C Step 9) + continue-on-error: true + run: go build -tags wazero ./... + - name: Build with -tags otel,wazero (full community-hub build) + continue-on-error: true + run: go build -tags otel,wazero ./... + - name: Go vulnerability check run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./... diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2cc901b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,108 @@ +# Changelog + +All notable changes to OwnCord are listed here. The repository's release +tooling (`npm run changelog`) auto-generates entries from commit messages +on each release; this file is the curated counterpart that calls out +behavioural changes operators must know about. + +## Unreleased — Phase B + C + +### Phase B — Acceleration + +- **Event persistence layer (Step 7).** A new `events` table backs the + WebSocket reconnect path. When a client's `last_seq` is too old for + the in-memory ring buffer (~1000 events), the server now falls back to + a SQLite query before forcing a full re-sync. The hub seeds its + monotonic sequence counter from `MAX(events.seq)` at startup so row + seqs and wrapped-payload seqs stay aligned across restarts. Configurable + via the new `event_persistence` block; **enabled by default** (see + "Behavioural changes" below). +- **Tiered reconnect telemetry.** `auth_ok` now includes a `replay_source` + field (`"none" | "buffer" | "db"`) so clients can attribute reconnection + behaviour. The same tier label is exported as the + `ws_reconnect_tier_total{tier}` counter. +- **OpenTelemetry skeleton (Step 8).** Public API + no-op default + provider in `Server/telemetry/`. Chi router middleware mounted + unconditionally. Service-layer spans on `MessageService.SendMessage`, + `PermissionService.HasChannelPerm`, + `ChannelService.ListVisibleChannels`, `DMService.CreateDM`, + `VoiceService.JoinChannel`, `InviteService.CreateInvite`, + `ModerationService.BanUser`, `BlockService.BlockUser`, + `UserService.UpdateProfile`. The real OTel SDK is gated behind + `-tags otel` and is currently a placeholder; wiring the upstream + modules is tracked in `PHASE_BC_LOCAL_TODO.md`. +- **Solid.js proof of concept (Step 6).** Two leaf components migrated + (`Badge`, `ChannelListItem`), Vite + JSX configured, store→signal + adapter landed. The remaining vanilla components remain in place; + migration is mechanical and tracked in the local TODO. + +### Phase C — Differentiation + +- **Plugin runtime skeleton (Step 9).** New `Server/plugin/` package + with manifest parser, on-disk loader, registry, and host capability + surfaces (`commands`, `events`, `storage`, `http`, `ui`). Manifest + format is JSON (`plugin.json`); the design's TOML format is gated + behind the `-tags wazero` build and tracked locally. +- **Plugin admin REST surface.** Lifecycle endpoints under + `/api/v1/admin/plugins`: list, enable, disable, uninstall, and the + new install path that accepts a multipart zip upload, validates it + zip-slip safe with size + symlink rejection, and atomically installs + it. Mounted under both `AdminIPRestrict` and the + `admin.RequireAdminAuth` session/permission middleware. +- **Plugin admin client bridge.** `pluginBridge.ts` mounts plugin UI + tabs in sandboxed iframes with origin-validated postMessage routing. + +### Security + +- **SSRF defense for `http` capability.** Plugin outbound HTTP requests + are now validated through `net/url.Parse`, suffix-matched with a dot + boundary (so `evil-api.example.com` does not match + `api.example.com`), and rejected for empty allowlist entries. A custom + `Transport.DialContext` re-resolves DNS on every dial and refuses any + resolved address in loopback / RFC1918 / RFC4193 / RFC6598 (CGN) / + link-local / multicast / unspecified ranges. Closes the DNS-rebinding + TOCTOU window. Response body is capped at 5 MiB. +- **Plugin manifest hardening.** `Manifest.Name` must match + `^[a-z0-9][a-z0-9_-]{0,63}$`. Entrypoint and UI tab asset paths are + rejected if absolute, non-canonical, contain `..`, or contain NUL + bytes / backslashes. +- **Plugin asset handler.** Defends against symlink escapes (rejected + at install time via `filepath.Walk` + `Lstat`) and prefix-without- + separator path traversal (via `filepath.Rel` check after join). +- **Plugin postMessage routing.** The host bridge looks up the trusted + pluginId via `e.source -> contentWindow` instead of trusting the + `pluginId` field in the message body. Spoofed messages from any + non-iframe source are dropped. + +### Behavioural changes operators must know about + +- **`event_persistence.enabled` defaults to `true`.** Every broadcast + WebSocket event is written to the `events` table, retained for + 24 hours by default, and pruned by a background goroutine every hour. + This is a new on-disk write path that did not exist before. Disable + it by adding to `config.yaml`: + ```yaml + event_persistence: + enabled: false + ``` +- **DM events are persisted under the same retention.** Operators with + GDPR or compliance requirements should review the retention window + and consider setting `event_persistence.enabled: false` until a + per-channel-type opt-out lands. +- **Plugin admin endpoints require admin session auth in addition to + the existing IP restriction.** A previous prerelease shipped with only + the IP gate; that has been corrected. + +### Known follow-up work (local toolchain required) + +See `PHASE_BC_LOCAL_TODO.md` for the full list. Highlights: + +- Real OpenTelemetry SDK wiring (needs `go get` of the upstream modules) +- Real Wazero runtime construction (needs `go get github.com/tetratelabs/wazero`) +- Postgres backend implementation (needs `make sqlc-generate`) +- Tinygo `.wasm` build of the example hello plugin +- Migration of the remaining vanilla TypeScript components to Solid.js +- Slash-command dispatcher in the WS layer (design TBD) + +These items each need a real developer machine with network access; no +in-sandbox pass can land them. diff --git a/PHASE_BC_LOCAL_TODO.md b/PHASE_BC_LOCAL_TODO.md index b3f6f1dd..81274512 100644 --- a/PHASE_BC_LOCAL_TODO.md +++ b/PHASE_BC_LOCAL_TODO.md @@ -107,9 +107,9 @@ Still TODO locally: the DB tier returns the missing events. The session test (`event_persister_test.go`) covers the persister in isolation but not the buffer→DB handoff inside `handleReconnect`. -- [ ] Add a `replay_source` field to the auth_ok payload so the client - can log the tier. The hub already records the tier in metrics; the - client surface change is a separate UX call. +- [x] Add a `replay_source` field to the auth_ok payload — landed in + Pass 4. `buildAuthOK` takes the tier as a parameter, "none" on + fresh connect, "buffer" or "db" on resume. - [x] Document the new `event_persistence` block in `defaultYAML` inside `Server/config/config.go` — landed in Pass 3. @@ -230,9 +230,11 @@ Still TODO locally: cd Server/plugin/examples/hello tinygo build -o hello.wasm -target wasi ./main.go ``` -- [ ] Implement plugin marketplace install path - (`POST /api/v1/admin/plugins/install` with multipart zip). The - handler is scaffolded but the install endpoint is currently absent. +- [x] Implement plugin marketplace install path + (`POST /api/v1/admin/plugins/install` with multipart zip) — landed + in Pass 4. `Registry.InstallFromZip` does zip-slip validation, no + symlinks, 16 MiB compressed cap, 64 MiB uncompressed cap, then + atomic rename into the plugin directory. - [ ] Replace plugin postgres stubs in `Server/store/postgres.go` with real `pgdbgen`-backed implementations once `make sqlc-generate` runs (same blocker as Phase B Step 7). diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 1ed8351d..6fa750c1 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -6,6 +6,7 @@ package api import ( + "io" "net/http" "strconv" @@ -14,6 +15,11 @@ import ( "github.com/owncord/server/store" ) +// maxPluginUploadBytes caps the multipart upload at 16 MiB to match the +// plugin.maxZipBytes ceiling. The handler enforces both layers because the +// outer MaxBytesReader gives a clean 413 instead of a partial extract. +const maxPluginUploadBytes = 16 * 1024 * 1024 + // PluginAdminHandler exposes plugin lifecycle operations to the admin panel. type PluginAdminHandler struct { registry *plugin.Registry @@ -27,12 +33,55 @@ func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http h := &PluginAdminHandler{registry: registry, store: st} r := chi.NewRouter() r.Get("/", h.list) + r.Post("/install", h.install) r.Post("/{id}/enable", h.enable) r.Post("/{id}/disable", h.disable) r.Delete("/{id}", h.uninstall) return r } +// install accepts a multipart upload with a single "plugin" file part +// containing a .zip. The zip is validated (zip-slip safe, no symlinks, +// uncompressed total capped, manifest required at root) and installed via +// Registry.InstallFromZip. Returns 201 with the new plugin name on success. +func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { + if h.registry == nil { + http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable) + return + } + // Hard cap on the request body before parsing multipart so a hostile + // client can't tie up parsing memory. + r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+1024) + if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil { + http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("plugin") + if err != nil { + http.Error(w, "missing 'plugin' file part", http.StatusBadRequest) + return + } + defer file.Close() //nolint:errcheck + + // Read the entire zip into memory — InstallFromZip needs an io.ReaderAt + // for archive/zip and the cap is small enough to be safe. + body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1)) + if err != nil { + http.Error(w, "read upload: "+err.Error(), http.StatusBadRequest) + return + } + if int64(len(body)) > maxPluginUploadBytes { + http.Error(w, "plugin upload too large", http.StatusRequestEntityTooLarge) + return + } + name, err := h.registry.InstallFromZip(r.Context(), body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"name": name}) +} + func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if h.store == nil { diff --git a/Server/plugin/host_http_test.go b/Server/plugin/host_http_test.go new file mode 100644 index 00000000..454a799c --- /dev/null +++ b/Server/plugin/host_http_test.go @@ -0,0 +1,121 @@ +// Pass 4 — host HTTP allowlist + IP guard tests. +// +// Locks in the SSRF defenses added in Pass 2 (dot-bounded host suffix +// matching, empty-entry rejection) and Pass 3 (RFC6598 CGN rejection). +package plugin + +import ( + "net" + "testing" +) + +func newTestRegistry(allowlist []string) *Registry { + return &Registry{cfg: Config{HTTPAllowlist: allowlist}} +} + +func TestHostAllowedDotBoundary(t *testing.T) { + r := newTestRegistry([]string{"api.example.com", "example.org"}) + cases := []struct { + host string + ok bool + }{ + {"api.example.com", true}, + {"v1.api.example.com", true}, + {"example.org", true}, + {"sub.example.org", true}, + // Sibling-domain attack — must NOT match. + {"evil-api.example.com", false}, + {"notexample.com", false}, + {"example.com", false}, // not in list + {"evil.com", false}, + {"", false}, + {"api.example.com.evil.com", false}, + } + for _, c := range cases { + got := r.hostAllowed(c.host) + if got != c.ok { + t.Errorf("hostAllowed(%q) = %v, want %v", c.host, got, c.ok) + } + } +} + +func TestHostAllowedEmptyEntryRejected(t *testing.T) { + r := newTestRegistry([]string{""}) + if r.hostAllowed("anything.com") { + t.Fatal("empty allowlist entry must NOT wildcard-match") + } + if r.hostAllowed("") { + t.Fatal("empty host must not match empty entry") + } +} + +func TestHostAllowedCaseInsensitive(t *testing.T) { + r := newTestRegistry([]string{"API.Example.COM"}) + if !r.hostAllowed("api.example.com") { + t.Fatal("hostAllowed should be case-insensitive") + } + if !r.hostAllowed("API.example.com") { + t.Fatal("hostAllowed should be case-insensitive") + } +} + +func TestHostAllowedTrailingDot(t *testing.T) { + r := newTestRegistry([]string{"api.example.com"}) + if !r.hostAllowed("api.example.com.") { + t.Fatal("FQDN trailing dot should match") + } +} + +func TestIPAllowedRejectsAllRanges(t *testing.T) { + cases := []string{ + "127.0.0.1", // loopback + "127.5.6.7", // loopback range + "10.0.0.1", // RFC1918 + "172.16.5.5", // RFC1918 + "172.31.255.255", // RFC1918 high + "192.168.1.1", // RFC1918 + "169.254.169.254", // AWS metadata / link-local + "100.64.5.5", // RFC6598 CGN + "100.127.255.255", // RFC6598 CGN high + "::1", // IPv6 loopback + "fc00::1", // RFC4193 ULA + "fe80::1", // IPv6 link-local + "0.0.0.0", // unspecified + "::", // IPv6 unspecified + "224.0.0.1", // multicast + "ff00::1", // IPv6 multicast + } + for _, addr := range cases { + ip := net.ParseIP(addr) + if ip == nil { + t.Fatalf("ParseIP(%q) failed", addr) + } + if err := ipAllowed(ip); err == nil { + t.Errorf("ipAllowed(%s) should have returned error", addr) + } + } +} + +func TestIPAllowedAcceptsPublic(t *testing.T) { + cases := []string{ + "8.8.8.8", + "1.1.1.1", + "203.0.113.5", // RFC5737 documentation but not in any reject set + "2606:4700:4700::1111", + } + for _, addr := range cases { + ip := net.ParseIP(addr) + if ip == nil { + t.Fatalf("ParseIP(%q) failed", addr) + } + if err := ipAllowed(ip); err != nil { + t.Errorf("ipAllowed(%s) should have been allowed, got %v", addr, err) + } + } +} + +func TestIPAllowedNilRejected(t *testing.T) { + if err := ipAllowed(nil); err == nil { + t.Fatal("nil IP should be rejected") + } +} diff --git a/Server/plugin/host_ui_test.go b/Server/plugin/host_ui_test.go new file mode 100644 index 00000000..e0842536 --- /dev/null +++ b/Server/plugin/host_ui_test.go @@ -0,0 +1,114 @@ +// Pass 4 — asset handler tests. +// +// Locks in the Pass 2 + Pass 3 hardening: only manifest-declared files are +// served, path traversal is rejected, prefix-without-separator escapes are +// rejected via the filepath.Rel check. +package plugin + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func newAssetTestInstance(t *testing.T, declaredAssets []string) (*Registry, *Instance, string) { + t.Helper() + dir := t.TempDir() + // The handler uses filepath.Dir(inst.WASMPath) as pluginDir. + wasmPath := filepath.Join(dir, "main.wasm") + if err := os.WriteFile(wasmPath, []byte("\x00asm"), 0o644); err != nil { + t.Fatal(err) + } + tabs := make([]UITab, len(declaredAssets)) + for i, a := range declaredAssets { + tabs[i] = UITab{ID: "tab", Asset: a} + // Create the file under the plugin dir so ServeFile can find it. + full := filepath.Join(dir, a) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("hello: "+a), 0o644); err != nil { + t.Fatal(err) + } + } + r := &Registry{} + inst := &Instance{ + ID: 1, + WASMPath: wasmPath, + Manifest: &Manifest{ + Name: "ui-test", + Version: "0.1.0", + Entrypoint: "main.wasm", + Permissions: []string{string(CapUI)}, + UI: UISpec{Tabs: tabs}, + }, + } + return r, inst, dir +} + +func TestAssetHandlerServesAllowedFile(t *testing.T) { + r, inst, _ := newAssetTestInstance(t, []string{"index.html"}) + h := r.AssetHandler(inst) + + req := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if got := w.Body.String(); got != "hello: index.html" { + t.Fatalf("unexpected body: %q", got) + } +} + +func TestAssetHandlerRejectsUnlistedFile(t *testing.T) { + r, inst, dir := newAssetTestInstance(t, []string{"index.html"}) + // File EXISTS in the plugin dir but is not declared in the manifest. + if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + h := r.AssetHandler(inst) + + req := httptest.NewRequest("GET", "/secret.txt", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for undeclared file, got %d", w.Code) + } +} + +func TestAssetHandlerRejectsTraversal(t *testing.T) { + r, inst, _ := newAssetTestInstance(t, []string{"index.html"}) + h := r.AssetHandler(inst) + + cases := []string{ + "/../../../etc/passwd", + "/..%2Fpasswd", + "/etc/passwd", + } + for _, p := range cases { + req := httptest.NewRequest("GET", p, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code == http.StatusOK { + t.Errorf("traversal path %q should not have returned 200", p) + } + } +} + +func TestAssetHandlerServesNestedAsset(t *testing.T) { + r, inst, _ := newAssetTestInstance(t, []string{"assets/app.js"}) + h := r.AssetHandler(inst) + + req := httptest.NewRequest("GET", "/assets/app.js", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} diff --git a/Server/plugin/loader_test.go b/Server/plugin/loader_test.go new file mode 100644 index 00000000..f73fa6fc --- /dev/null +++ b/Server/plugin/loader_test.go @@ -0,0 +1,99 @@ +// Pass 4 — loader symlink rejection tests. +// +// Locks in the Pass 3 defense against malicious plugin .zip packages that +// ship symlinks to host filesystem paths. http.ServeFile follows symlinks +// transparently, so the only safe time to reject them is at install / +// directory-scan time. +package plugin + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestRejectSymlinksUnderClean(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "subdir", "nested.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + if err := rejectSymlinksUnder(dir); err != nil { + t.Fatalf("clean directory should pass, got: %v", err) + } +} + +func TestRejectSymlinksUnderFindsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + // Create an evil symlink pointing at /etc/passwd. + link := filepath.Join(dir, "evil.html") + if err := os.Symlink("/etc/passwd", link); err != nil { + t.Skipf("symlink creation failed (likely unsupported FS): %v", err) + } + if err := rejectSymlinksUnder(dir); err == nil { + t.Fatal("expected rejectSymlinksUnder to refuse the symlink") + } +} + +func TestRejectSymlinksUnderFindsNestedSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + dir := t.TempDir() + sub := filepath.Join(dir, "assets") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(sub, "leak") + if err := os.Symlink("/etc/passwd", link); err != nil { + t.Skipf("symlink creation failed: %v", err) + } + if err := rejectSymlinksUnder(dir); err == nil { + t.Fatal("expected nested symlink to be rejected") + } +} + +func TestScanPluginDirectoryRejectsSymlinkEntrypoint(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + root := t.TempDir() + pluginDir := filepath.Join(root, "evil") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + // Manifest claims hello.wasm; we'll make hello.wasm a symlink. + manifest := []byte(`{ + "name": "evil", + "version": "0.1.0", + "entrypoint": "hello.wasm" + }`) + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o644); err != nil { + t.Fatal(err) + } + // Create a real target then symlink to it (so the target exists; the + // symlink itself is what we want to reject). + target := filepath.Join(root, "target.bin") + if err := os.WriteFile(target, []byte("\x00asm"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(pluginDir, "hello.wasm")); err != nil { + t.Skipf("symlink creation failed: %v", err) + } + _, err := scanPluginDirectory(root) + if err == nil { + t.Fatal("scanPluginDirectory should reject plugin with symlinked entrypoint") + } +} diff --git a/Server/plugin/manifest_test.go b/Server/plugin/manifest_test.go new file mode 100644 index 00000000..d11d5f24 --- /dev/null +++ b/Server/plugin/manifest_test.go @@ -0,0 +1,145 @@ +// Pass 4 — security-sensitive manifest validation tests. +// +// Locks in the regex / path-traversal / NUL-byte rules added in Pass 2 so +// regressions in Manifest.Validate are caught at CI time instead of via +// manual review. +package plugin + +import ( + "strings" + "testing" +) + +func TestPluginNameRegexp(t *testing.T) { + cases := []struct { + name string + ok bool + }{ + {"hello", true}, + {"game-detection", true}, + {"a", true}, + {"a1_b-c", true}, + {"abcdefghijklmnopqrstuvwxyz0123456789_-abcdefghijklmnopqrstuvwxyz", true}, // 64 chars + {"abcdefghijklmnopqrstuvwxyz0123456789_-abcdefghijklmnopqrstuvwxyz0", false}, // 65 chars + {"", false}, + {"Hello", false}, + {"_leading", false}, + {"-leading", false}, + {"..", false}, + {"a/b", false}, + {"a.b", false}, + {"a b", false}, + {"a\x00b", false}, + {"hello!", false}, + } + for _, c := range cases { + got := pluginNameRegexp.MatchString(c.name) + if got != c.ok { + t.Errorf("pluginNameRegexp.MatchString(%q) = %v, want %v", c.name, got, c.ok) + } + } +} + +func TestValidateRelativePath(t *testing.T) { + cases := []struct { + path string + ok bool + }{ + {"a.wasm", true}, + {"assets/index.html", true}, + {"dir/sub/file.js", true}, + {"hello.wasm", true}, + // failures + {"", false}, + {"/abs/path", false}, + {"../escape", false}, + {"./not-clean", false}, + {"dir//double", false}, + {"dir/", false}, + {"dir\\win", false}, + {"file\x00name", false}, + {"..", false}, + {".", false}, + {"a/../b", false}, + {"a/./b", false}, + } + for _, c := range cases { + err := validateRelativePath(c.path) + got := err == nil + if got != c.ok { + t.Errorf("validateRelativePath(%q) ok=%v err=%v, want ok=%v", c.path, got, err, c.ok) + } + } +} + +func TestManifestValidateRejectsBadName(t *testing.T) { + m := &Manifest{ + Name: "Bad Name", + Version: "0.1.0", + Entrypoint: "hello.wasm", + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation failure for invalid name") + } +} + +func TestManifestValidateRejectsBadAsset(t *testing.T) { + m := &Manifest{ + Name: "hello", + Version: "0.1.0", + Entrypoint: "hello.wasm", + UI: UISpec{ + Tabs: []UITab{ + {ID: "main", Asset: "../../../etc/passwd"}, + }, + }, + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation failure for traversal asset") + } +} + +func TestManifestValidateRejectsAbsoluteEntrypoint(t *testing.T) { + m := &Manifest{ + Name: "hello", + Version: "0.1.0", + Entrypoint: "/etc/passwd.wasm", + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation failure for absolute entrypoint") + } +} + +func TestManifestValidateAcceptsMinimal(t *testing.T) { + m := &Manifest{ + Name: "hello", + Version: "0.1.0", + Entrypoint: "hello.wasm", + } + if err := m.Validate(); err != nil { + t.Fatalf("expected minimal manifest to validate, got: %v", err) + } +} + +func TestManifestValidateRejectsOversizedVersion(t *testing.T) { + m := &Manifest{ + Name: "hello", + Version: strings.Repeat("v", 65), + Entrypoint: "hello.wasm", + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation failure for oversized version") + } +} + +func TestManifestValidateRejectsUnknownPermission(t *testing.T) { + m := &Manifest{ + Name: "hello", + Version: "0.1.0", + Entrypoint: "hello.wasm", + Permissions: []string{"commands", "filesystem"}, + } + if err := m.Validate(); err == nil { + t.Fatal("expected validation failure for unknown permission") + } +} diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 1e0d83ff..141e87a2 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -12,9 +12,14 @@ package plugin import ( + "archive/zip" "context" "fmt" + "io" "log/slog" + "os" + "path/filepath" + "strings" "sync" "github.com/owncord/server/store" @@ -142,6 +147,190 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error return nil } +// InstallFromZip extracts a plugin .zip uploaded via the admin API into a +// temp directory, validates it (zip-slip safe, no symlinks, size-capped), +// then renames it into the plugin directory and registers it via +// installFromDisk. Returns the new plugin name on success. +// +// The zip must contain a top-level plugin.json. The plugin's directory name +// is taken from manifest.Name (validated by Manifest.Validate to a strict +// charset). Re-installing an existing plugin replaces it. +const ( + maxZipBytes = 16 * 1024 * 1024 // 16 MiB compressed + maxUncompressedSum = 64 * 1024 * 1024 // 64 MiB total uncompressed +) + +func (r *Registry) InstallFromZip(ctx context.Context, zipBytes []byte) (string, error) { + if r == nil || r.cfg.Directory == "" { + return "", fmt.Errorf("plugin runtime not configured") + } + if int64(len(zipBytes)) > maxZipBytes { + return "", fmt.Errorf("plugin zip exceeds %d bytes", maxZipBytes) + } + zr, err := zip.NewReader(bytesReaderAt(zipBytes), int64(len(zipBytes))) + if err != nil { + return "", fmt.Errorf("invalid zip: %w", err) + } + + // Stage 1: extract into a temp dir under the plugin directory. + if err := os.MkdirAll(r.cfg.Directory, 0o750); err != nil { + return "", fmt.Errorf("create plugin dir: %w", err) + } + stage, err := os.MkdirTemp(r.cfg.Directory, ".install-") + if err != nil { + return "", fmt.Errorf("create staging dir: %w", err) + } + cleanup := func() { _ = os.RemoveAll(stage) } + + stageAbs, absErr := filepath.Abs(stage) + if absErr != nil { + cleanup() + return "", fmt.Errorf("abs staging dir: %w", absErr) + } + + var totalUncompressed int64 + for _, f := range zr.File { + // Reject symlinks, devices, and any non-regular file mode. + if !f.Mode().IsRegular() && !f.Mode().IsDir() { + cleanup() + return "", fmt.Errorf("plugin zip: refusing non-regular entry %q (mode=%v)", f.Name, f.Mode()) + } + if f.Mode()&os.ModeSymlink != 0 { + cleanup() + return "", fmt.Errorf("plugin zip: refusing symlink %q", f.Name) + } + // Reject zip-slip: cleaned absolute path must stay rooted at the + // staging directory. + clean := filepath.Clean(f.Name) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) || strings.Contains(clean, "..\\") { + cleanup() + return "", fmt.Errorf("plugin zip: refusing path-traversal entry %q", f.Name) + } + dest := filepath.Join(stageAbs, clean) + destAbs, dErr := filepath.Abs(dest) + if dErr != nil { + cleanup() + return "", dErr + } + rel, relErr := filepath.Rel(stageAbs, destAbs) + if relErr != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + cleanup() + return "", fmt.Errorf("plugin zip: refusing escape %q", f.Name) + } + + if f.Mode().IsDir() { + if err := os.MkdirAll(destAbs, 0o750); err != nil { + cleanup() + return "", err + } + continue + } + if err := os.MkdirAll(filepath.Dir(destAbs), 0o750); err != nil { + cleanup() + return "", err + } + rc, oErr := f.Open() + if oErr != nil { + cleanup() + return "", oErr + } + out, cErr := os.OpenFile(destAbs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640) + if cErr != nil { + _ = rc.Close() + cleanup() + return "", cErr + } + // Cap each file at the remaining uncompressed budget so a zip bomb + // can't OOM the host. + remaining := maxUncompressedSum - totalUncompressed + if remaining <= 0 { + _ = rc.Close() + _ = out.Close() + cleanup() + return "", fmt.Errorf("plugin zip: uncompressed total exceeds %d bytes", maxUncompressedSum) + } + n, copyErr := io.CopyN(out, rc, remaining+1) + _ = rc.Close() + _ = out.Close() + if copyErr != nil && copyErr != io.EOF { + cleanup() + return "", copyErr + } + if n > remaining { + cleanup() + return "", fmt.Errorf("plugin zip: uncompressed total exceeds %d bytes", maxUncompressedSum) + } + totalUncompressed += n + } + + // Stage 2: parse the manifest now that the staging dir is fully populated. + manifestPath := filepath.Join(stageAbs, "plugin.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + cleanup() + return "", fmt.Errorf("plugin zip: missing plugin.json at root: %w", err) + } + manifest, err := ParseManifest(raw) + if err != nil { + cleanup() + return "", err + } + // Validate the staged contents the same way scanPluginDirectory does. + if err := rejectSymlinksUnder(stageAbs); err != nil { + cleanup() + return "", err + } + wasmPath := filepath.Join(stageAbs, manifest.Entrypoint) + if info, statErr := os.Lstat(wasmPath); statErr != nil { + cleanup() + return "", fmt.Errorf("entrypoint %s missing: %w", manifest.Entrypoint, statErr) + } else if info.Mode()&os.ModeSymlink != 0 { + cleanup() + return "", fmt.Errorf("entrypoint %s is a symlink", manifest.Entrypoint) + } + + // Stage 3: atomically rename into the canonical plugin name directory. + finalDir := filepath.Join(r.cfg.Directory, manifest.Name) + // If a previous version exists, remove it. The store row is replaced by + // installFromDisk via the existing UPSERT path. + if _, err := os.Stat(finalDir); err == nil { + if err := os.RemoveAll(finalDir); err != nil { + cleanup() + return "", fmt.Errorf("remove existing plugin dir: %w", err) + } + } + if err := os.Rename(stageAbs, finalDir); err != nil { + cleanup() + return "", fmt.Errorf("install rename: %w", err) + } + + // Stage 4: register via the existing on-disk install path. + if err := r.installFromDisk(ctx, foundPlugin{ + Manifest: manifest, + Dir: finalDir, + WASMPath: filepath.Join(finalDir, manifest.Entrypoint), + }); err != nil { + return manifest.Name, fmt.Errorf("installFromDisk: %w", err) + } + return manifest.Name, nil +} + +// bytesReaderAt is a tiny wrapper that satisfies io.ReaderAt for a byte +// slice. archive/zip needs ReaderAt; bytes.Reader provides it but importing +// "bytes" alongside the existing "io" surface keeps the import block tight. +type bytesReaderAt []byte + +func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off < 0 || off >= int64(len(b)) { + return 0, io.EOF + } + n := copy(p, b[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} + // activateAll attempts to compile + register host-API hooks for every plugin // row in the PluginStore that is marked enabled. The default build is a // no-op (no Wazero modules to compile). diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index b8ce0c46..e34f3dab 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -104,8 +104,10 @@ func (h *Hub) PubSubForTest() *PubSub { } // BuildAuthOKForTest exposes Hub.buildAuthOK for external tests. +// Defaults to replay_source="none" since most callers test the fresh-connect +// path; tests that care about the resume tier can call buildAuthOK directly. func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte { - return h.buildAuthOK(user, roleName) + return h.buildAuthOK(user, roleName, "none") } // BuildReadyForTest exposes Hub.buildReady for external tests. diff --git a/Server/ws/extract_event_type_test.go b/Server/ws/extract_event_type_test.go new file mode 100644 index 00000000..a27a118c --- /dev/null +++ b/Server/ws/extract_event_type_test.go @@ -0,0 +1,54 @@ +// Pass 4 — extractEventType tests. +// +// Locks in the Pass 3 byte-scan helper that pulls the wire-format "type" +// field out of a wrapped JSON envelope without a full unmarshal. Tests cover +// the happy paths and the defensive rejects (control chars, escaped quotes, +// length cap, malformed input). +package ws + +import ( + "strings" + "testing" +) + +func TestExtractEventType(t *testing.T) { + cases := []struct { + name string + payload string + want string + }{ + {"type first", `{"type":"chat_message","seq":1}`, "chat_message"}, + {"seq first", `{"seq":1,"type":"voice_join"}`, "voice_join"}, + {"empty object", `{}`, ""}, + {"missing type", `{"seq":1,"channel":2}`, ""}, + {"control char", "{\"type\":\"with\x01ctrl\"}", ""}, + {"escaped quote", `{"type":"a\"b"}`, ""}, + {"empty type", `{"type":""}`, ""}, + {"empty payload", ``, ""}, + {"non-json", `garbage`, ""}, + {"plausible nested", `{"type":"x","payload":{"type":"y"}}`, "x"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := extractEventType([]byte(c.payload)) + if got != c.want { + t.Errorf("extractEventType(%q) = %q, want %q", c.payload, got, c.want) + } + }) + } +} + +func TestExtractEventTypeLengthCap(t *testing.T) { + long := `{"type":"` + strings.Repeat("a", 100) + `"}` + if got := extractEventType([]byte(long)); got != "" { + t.Fatalf("expected empty for >64-char type, got %q", got) + } + exactly64 := `{"type":"` + strings.Repeat("a", 64) + `"}` + if got := extractEventType([]byte(exactly64)); got != strings.Repeat("a", 64) { + t.Fatalf("64-char type should be accepted, got %q", got) + } + exactly65 := `{"type":"` + strings.Repeat("a", 65) + `"}` + if got := extractEventType([]byte(exactly65)); got != "" { + t.Fatalf("65-char type should be rejected, got %q", got) + } +} diff --git a/Server/ws/hub_seedseq_test.go b/Server/ws/hub_seedseq_test.go new file mode 100644 index 00000000..1c3eacbb --- /dev/null +++ b/Server/ws/hub_seedseq_test.go @@ -0,0 +1,59 @@ +// Pass 4 — Hub.SeedSeq tests. +// +// Locks in the Pass 2 fix that aligns the in-memory monotonic counter with +// the persisted MAX(events.seq) at startup. SeedSeq must never go backwards +// even under concurrent calls and must integrate with nextSeq() so the next +// allocated seq is greater than every previously persisted row. +package ws + +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestSeedSeqMonotonic(t *testing.T) { + h := &Hub{} + h.SeedSeq(100) + if got := atomic.LoadUint64(&h.seq); got != 100 { + t.Fatalf("after SeedSeq(100), seq = %d, want 100", got) + } + // Lower seed must be a no-op. + h.SeedSeq(50) + if got := atomic.LoadUint64(&h.seq); got != 100 { + t.Fatalf("after SeedSeq(50), seq = %d, want 100 (no backwards)", got) + } + // Higher seed must take effect. + h.SeedSeq(500) + if got := atomic.LoadUint64(&h.seq); got != 500 { + t.Fatalf("after SeedSeq(500), seq = %d, want 500", got) + } +} + +func TestSeedSeqThenNextSeq(t *testing.T) { + h := &Hub{} + h.SeedSeq(1000) + got := h.nextSeq() + if got != 1001 { + t.Fatalf("nextSeq after SeedSeq(1000) = %d, want 1001", got) + } +} + +func TestSeedSeqConcurrent(t *testing.T) { + h := &Hub{} + const n = 100 + var wg sync.WaitGroup + wg.Add(n) + for i := 1; i <= n; i++ { + i := i + go func() { + defer wg.Done() + h.SeedSeq(uint64(i * 7)) // distinct values, max = n*7 + }() + } + wg.Wait() + want := uint64(n * 7) + if got := atomic.LoadUint64(&h.seq); got != want { + t.Fatalf("after concurrent seeds, seq = %d, want %d", got, want) + } +} diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 546baa6f..c4e6a228 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -164,9 +164,11 @@ func (h *Hub) handleReconnect( // will be drained once the pumps begin. h.registerNow(c) - // Replay succeeded — send auth_ok then missed events. - slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName)); err != nil { + // Replay succeeded — send auth_ok then missed events. The replay tier + // is included in the payload so the client can attribute reconnect + // behaviour without separate metric scraping. + slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource) + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, replaySource)); err != nil { slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") @@ -302,7 +304,7 @@ func (h *Hub) handleFreshConnect( // Fresh connection or replay fallback: full auth_ok + ready flow. slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName)); err != nil { + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, "none")); err != nil { slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") @@ -518,7 +520,12 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db // buildAuthOK constructs the auth_ok server→client message. // Per PROTOCOL.md, user object contains only id, username, avatar, role (no status). -func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte { +// +// replaySource records which reconnection tier served this client: +// - "none" — fresh connection or full re-sync (no resume) +// - "buffer" — resume served from the in-memory ring buffer +// - "db" — resume served from the persistent EventStore (Phase B Step 7) +func (h *Hub) buildAuthOK(user *db.User, roleName string, replaySource string) []byte { var avatarVal any if user.Avatar != nil { avatarVal = *user.Avatar @@ -535,8 +542,9 @@ func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte { "avatar": avatarVal, "role": roleName, }, - "server_name": serverName, - "motd": motd, + "server_name": serverName, + "motd": motd, + "replay_source": replaySource, }, }) }