From ca10ee5ecb430b83154aae3231072807569c815d Mon Sep 17 00:00:00 2001 From: TeoSlayer Date: Mon, 22 Jun 2026 17:40:55 +0300 Subject: [PATCH] Reconcile dashboard /api/stats auth parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard frontend authenticated to /api/stats with ?token=, but the endpoint is wrapped in requireAdminToken (PR #50) which only accepts an admin token via X-Admin-Token header or admin_token= query. ?token= was ignored by the gate, so operator stats requests silently 401'd, and the inner dashboard-token elevation check had become dead code behind the admin gate. Reconcile on the admin gate (the secure, consistent option — the stored token is the same admin token /api/pulse already uses): the frontend now sends admin_token=, and the handler returns the elevated payload once requireAdminToken has confirmed a valid admin token reached it. Auth strength is unchanged; a missing/wrong token and the legacy ?token= still 401. Factor route registration into buildMux() so the real /api/stats auth path is testable, and add coverage for the accepted and rejected auth cases. --- dashboard/dashboard.go | 36 ++++++++---- dashboard/zz_stats_auth_test.go | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 dashboard/zz_stats_auth_test.go diff --git a/dashboard/dashboard.go b/dashboard/dashboard.go index 3091d01..7bd2be0 100644 --- a/dashboard/dashboard.go +++ b/dashboard/dashboard.go @@ -783,6 +783,16 @@ func localhostOnly(next http.HandlerFunc) http.HandlerFunc { // Serve starts an HTTP server serving the dashboard UI and stats API. func (h *Handler) Serve(addr string) error { + mux := h.buildMux() + slog.Info("dashboard listening", "addr", addr) + return http.ListenAndServe(addr, mux) +} + +// buildMux constructs the dashboard's route table. Factored out of +// Serve so tests can exercise the real routes (e.g. the /api/stats +// auth path) against an httptest server without re-implementing +// registration and drifting from production wiring. +func (h *Handler) buildMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -882,14 +892,15 @@ func (h *Handler) Serve(addr string) error { mux.HandleFunc("/api/stats", h.requireAdminToken(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - authenticated := false - if token := r.URL.Query().Get("token"); token != "" { - dt := h.cb.GetDashboardToken() - if dt != "" && subtle.ConstantTimeCompare([]byte(token), []byte(dt)) == 1 { - authenticated = true - } - } - _ = json.NewEncoder(w).Encode(h.buildStatsResponse(authenticated)) + // requireAdminToken (PR #50) already verified a valid admin + // token reached this handler — so the caller is an authenticated + // operator and gets the elevated per-network payload. The old + // inner ?token= dashboard-token check became dead once the + // endpoint moved behind the admin gate (the gate 401s anything + // without the admin token first), and the dashboard frontend + // sent ?token= which the gate ignored, so operator stats silently + // 401'd. The frontend now sends admin_token=; this matches it. + _ = json.NewEncoder(w).Encode(h.buildStatsResponse(true)) })) mux.HandleFunc("/api/nodes", func(w http.ResponseWriter, r *http.Request) { @@ -1551,8 +1562,7 @@ func (h *Handler) Serve(addr string) error { // because adding a router dependency for three routes isn't worth it. mux.HandleFunc("/api/admin/networks/", h.requireAdminToken(h.serveMembershipAdmin)) - slog.Info("dashboard listening", "addr", addr) - return http.ListenAndServe(addr, mux) + return mux } // serveMembershipAdmin dispatches the three /api/admin/networks/{id}/members* @@ -2163,7 +2173,11 @@ function update(){ // directly. Token-aware dashboard-token path retained for operators // who set one in localStorage. var t=getToken(); - var url=t?('/api/stats?token='+encodeURIComponent(t)):'/api/public-stats'; + // /api/stats is admin-gated (PR #50). The operator's stored token is + // the admin token (same one /api/pulse uses below), so send it as + // admin_token to clear the gate. Without this the request was sent as + // ?token= — which the admin gate ignores — and silently 401'd. + var url=t?('/api/stats?admin_token='+encodeURIComponent(t)):'/api/public-stats'; fetch(url).then(function(r){ if(!r.ok)return null; return r.json(); diff --git a/dashboard/zz_stats_auth_test.go b/dashboard/zz_stats_auth_test.go new file mode 100644 index 0000000..4187ef7 --- /dev/null +++ b/dashboard/zz_stats_auth_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package dashboard + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestStatsAuth_AdminTokenQueryParam pins the reconciled auth path: +// /api/stats is admin-gated, and the dashboard frontend authenticates +// with admin_token= (the same token it sends to /api/pulse). +// Previously the frontend sent ?token=, which the admin gate ignores, +// so operator stats silently 401'd. +func TestStatsAuth_AdminTokenQueryParam(t *testing.T) { + t.Parallel() + cb := minimalCallbacks() + cb.GetAdminToken = func() string { return "op-secret" } + // Mark the payload so we can confirm the elevated (authenticated) + // branch ran. + cb.BuildStatsPayload = func(authenticated bool) map[string]interface{} { + return map[string]interface{}{"authenticated": authenticated} + } + h := NewHandler(cb) + srv := httptest.NewServer(h.buildMux()) + defer srv.Close() + + // admin_token query param — what the frontend now sends — is accepted. + resp, err := http.Get(srv.URL + "/api/stats?admin_token=op-secret") + if err != nil { + t.Fatalf("GET /api/stats: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin_token query: status %d, want 200", resp.StatusCode) + } + + // X-Admin-Token header is accepted too. + req, _ := http.NewRequest("GET", srv.URL+"/api/stats", nil) + req.Header.Set("X-Admin-Token", "op-secret") + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /api/stats (header): %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + t.Fatalf("X-Admin-Token header: status %d, want 200", resp2.StatusCode) + } +} + +// TestStatsAuth_RejectsBadAndMissingToken confirms the gate is not +// weakened: a missing token, a wrong token, and the legacy ?token= +// (which the admin gate does not accept) are all rejected. +func TestStatsAuth_RejectsBadAndMissingToken(t *testing.T) { + t.Parallel() + cb := minimalCallbacks() + cb.GetAdminToken = func() string { return "op-secret" } + h := NewHandler(cb) + srv := httptest.NewServer(h.buildMux()) + defer srv.Close() + + cases := []struct { + name string + url string + }{ + {"missing", srv.URL + "/api/stats"}, + {"wrong admin_token", srv.URL + "/api/stats?admin_token=nope"}, + {"legacy token param not honored by gate", srv.URL + "/api/stats?token=op-secret"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp, err := http.Get(c.url) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("%s: status %d, want 401", c.name, resp.StatusCode) + } + }) + } +} + +// TestStatsAuth_UnconfiguredLocksShut confirms that with no admin token +// configured the endpoint is locked shut (matches the PR #50 lockdown). +func TestStatsAuth_UnconfiguredLocksShut(t *testing.T) { + t.Parallel() + h := NewHandler(minimalCallbacks()) // GetAdminToken returns "" + srv := httptest.NewServer(h.buildMux()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/stats?admin_token=anything") + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("unconfigured: status %d, want 401", resp.StatusCode) + } +}