diff --git a/CHANGELOG.md b/CHANGELOG.md index 9383cc5..e3b5c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project are documented here. The format is based on ### Fixed +- **fix(security, HIGH): authenticate the LAN management API and stop serializing the cloud token.** The daemon binds all interfaces as root with zero auth: `GET /api/config` and `/api/system` embedded the cloud token, and module install/stop plus config writes were anonymous. Reads stay open for the on-device dashboard, but the token is redacted from every response and all mutating routes require `Authorization: Bearer ` (constant-time compare; fail-open only while unprovisioned). Pinned by `httpapi_auth_test.go`. - `pr-agent` lane: fork-triggered `/` commands are now refused, and the AI call's budget fits inside its step. Three defects, one of them only visible once the first was fixed. diff --git a/httpapi.go b/httpapi.go index 91a169a..8c87d7f 100644 --- a/httpapi.go +++ b/httpapi.go @@ -3,6 +3,7 @@ package main import ( + "crypto/subtle" "encoding/json" "fmt" "log" @@ -10,7 +11,47 @@ import ( "time" ) -func (a *Agent) startHTTPServer(port int) *http.Server { +// SEC-HARDENING (2026-09-14, HIGH): the LAN management API is reachable +// unauthenticated (binds all interfaces, runs as root). Reads stay open for +// the on-device dashboard, but (a) the cloud token is NEVER serialized and +// (b) every mutating route requires the device Bearer token. + +// sanitizedConfig returns the device config with the cloud token stripped. +// The token authenticates the device toward edge.wave.online; serving it on +// an unauthenticated LAN endpoint leaks it to any network peer (and to every +// browser that opens the dashboard, which polls /api/system). +func (a *Agent) sanitizedConfig() DeviceConfig { + a.mu.RLock() + defer a.mu.RUnlock() + c := a.config + c.CloudToken = "" + return c +} + +// requireDeviceAuth gates mutating routes on the device Bearer token. +// Fail-open ONLY while unprovisioned (no token exists yet, nothing to steal); +// once provisioned, missing/wrong credentials are rejected. +func (a *Agent) requireDeviceAuth(w http.ResponseWriter, r *http.Request) bool { + a.mu.RLock() + token := a.config.CloudToken + a.mu.RUnlock() + if token == "" { + return true + } + got := r.Header.Get("Authorization") + if got == "" { + http.Error(w, "missing Authorization: Bearer ", 401) + return false + } + want := "Bearer " + token + if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 { + http.Error(w, "forbidden", 403) + return false + } + return true +} + +func (a *Agent) buildMux() *http.ServeMux { mux := http.NewServeMux() // Web UI (embedded dashboard) @@ -26,10 +67,15 @@ func (a *Agent) startHTTPServer(port int) *http.Server { }) }) - // System info + // System info (token-redacted; the dashboard polls this every 5s) mux.HandleFunc("/api/system", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(a.SystemInfo()) + info := a.SystemInfo() + if dev, ok := info["device"].(DeviceConfig); ok { + dev.CloudToken = "" + info["device"] = dev + } + json.NewEncoder(w).Encode(info) }) // Module list @@ -46,12 +92,15 @@ func (a *Agent) startHTTPServer(port int) *http.Server { json.NewEncoder(w).Encode(a.HealthCheck()) }) - // Module install + // Module install (privileged: executes install.sh as root — device auth required) mux.HandleFunc("/api/modules/install", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", 405) return } + if !a.requireDeviceAuth(w, r) { + return + } var req struct { Name string `json:"name"` } @@ -67,12 +116,15 @@ func (a *Agent) startHTTPServer(port int) *http.Server { json.NewEncoder(w).Encode(map[string]string{"status": "installed", "module": req.Name}) }) - // Module stop + // Module stop (privileged — device auth required) mux.HandleFunc("/api/modules/stop", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", 405) return } + if !a.requireDeviceAuth(w, r) { + return + } var req struct { Name string `json:"name"` } @@ -88,13 +140,16 @@ func (a *Agent) startHTTPServer(port int) *http.Server { json.NewEncoder(w).Encode(map[string]string{"status": "stopped", "module": req.Name}) }) - // Device config + // Device config (reads: token-redacted; writes: device auth required) mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(a.config) + json.NewEncoder(w).Encode(a.sanitizedConfig()) case http.MethodPut: + if !a.requireDeviceAuth(w, r) { + return + } var updates map[string]string if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { http.Error(w, err.Error(), 400) @@ -146,9 +201,13 @@ func (a *Agent) startHTTPServer(port int) *http.Server { } }) + return mux +} + +func (a *Agent) startHTTPServer(port int) *http.Server { server := &http.Server{ Addr: fmt.Sprintf(":%d", port), - Handler: mux, + Handler: a.buildMux(), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } diff --git a/httpapi_auth_test.go b/httpapi_auth_test.go new file mode 100644 index 0000000..c53ef0a --- /dev/null +++ b/httpapi_auth_test.go @@ -0,0 +1,99 @@ +package main + +// SEC-HARDENING (2026-09-14, HIGH): the LAN management API bound all interfaces +// with zero auth (root daemon). Reads stay open for the on-device dashboard, +// but the cloud token is never serialized and every mutating route requires +// the device Bearer token. +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testAgent() *Agent { + return &Agent{ + config: DeviceConfig{ + DeviceID: "dev-1", + DeviceName: "test", + Platform: "linux", + Profile: "default", + CloudToken: "wvtok_test_secret", + }, + modules: map[string]*ModuleState{}, + } +} + +func TestConfigRedactsCloudToken(t *testing.T) { + srv := httptest.NewServer(testAgent().buildMux()) + defer srv.Close() + res, err := http.Get(srv.URL + "/api/config") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var body map[string]any + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if tok, _ := body["cloud_token"].(string); tok != "" { + t.Fatalf("cloud token leaked in /api/config: %q", tok) + } + if body["device_id"] != "dev-1" { + t.Fatalf("expected device fields to survive redaction, got %v", body) + } +} + +func TestMutatingRoutesRequireDeviceAuth(t *testing.T) { + srv := httptest.NewServer(testAgent().buildMux()) + defer srv.Close() + + // no credentials → 401 + res, err := http.Post(srv.URL+"/api/modules/stop", "application/json", strings.NewReader(`{"name":"camera"}`)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 401 { + t.Fatalf("stop without auth = %d, want 401", res.StatusCode) + } + + // wrong credentials → 403 (proves the handler reached auth, not the module) + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/modules/stop", strings.NewReader(`{"name":"camera"}`)) + req.Header.Set("Authorization", "Bearer wrong") + res2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res2.Body.Close() + if res2.StatusCode != 403 { + t.Fatalf("stop with wrong auth = %d, want 403", res2.StatusCode) + } + + // PUT config without auth → 401 + req3, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/config", strings.NewReader(`{"device_name":"x"}`)) + res3, err := http.DefaultClient.Do(req3) + if err != nil { + t.Fatal(err) + } + res3.Body.Close() + if res3.StatusCode != 401 { + t.Fatalf("config PUT without auth = %d, want 401", res3.StatusCode) + } +} + +func TestReadsStayOpen(t *testing.T) { + srv := httptest.NewServer(testAgent().buildMux()) + defer srv.Close() + for _, p := range []string{"/health", "/api/system", "/api/modules", "/metrics"} { + res, err := http.Get(srv.URL + p) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 200 { + t.Fatalf("GET %s = %d, want 200 (dashboard reads must stay open)", p, res.StatusCode) + } + } +} diff --git a/main.go b/main.go index ba838b1..cb4c2d1 100644 --- a/main.go +++ b/main.go @@ -123,7 +123,7 @@ func (a *Agent) saveConfig() error { if err != nil { return err } - return os.WriteFile(filepath.Join(ConfigDir, "device.json"), data, 0644) + return os.WriteFile(filepath.Join(ConfigDir, "device.json"), data, 0600) // SEC-HARDENING (2026-09-14): token-bearing config is owner-only, never world-readable } // safeNamePattern is the allowlist for caller-supplied module and profile