Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -739,9 +739,10 @@ func (l *ipRateLimiter) middleware(maxReqs int, window time.Duration, next http.
// requireAdminToken wraps next so it returns 401 Unauthorized to anyone
// that doesn't present the operator-configured admin token. The token
// may be supplied via X-Admin-Token header (preferred for write paths)
// or admin_token=... query parameter (read-only convenience). When no
// admin token is configured on the server the wrapped path is locked
// shut entirely — operators must set -admin-token to enable access.
// or admin_token=... query parameter (GET only — read-only convenience;
// mutations require the header to prevent CSRF from leaked URLs).
// When no admin token is configured on the server the wrapped path is
// locked shut entirely — operators must set -admin-token to enable access.
//
// Used to lock down the previously-public stats / pulse / badge /
// snapshot endpoints so polo.pilotprotocol.network can expose only the
Expand All @@ -750,7 +751,10 @@ func (l *ipRateLimiter) middleware(maxReqs int, window time.Duration, next http.
func (h *Handler) requireAdminToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Admin-Token")
if token == "" {
// Query-param fallback only for GET (read-only, no CSRF risk).
// POST/PUT/DELETE require the header so a leaked URL cannot
// be used for cross-site mutation attacks.
if token == "" && r.Method == http.MethodGet {
token = r.URL.Query().Get("admin_token")
}
adminToken := h.cb.GetAdminToken()
Expand Down
37 changes: 37 additions & 0 deletions dashboard/zz_stats_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package dashboard

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -99,3 +100,39 @@ func TestStatsAuth_UnconfiguredLocksShut(t *testing.T) {
t.Errorf("unconfigured: status %d, want 401", resp.StatusCode)
}
}

// TestAdminToken_CSRF_MutationRejectsQueryParam confirms that
// query-param admin_token is REJECTED on mutation endpoints (POST/PUT/...)
// — these require the X-Admin-Token header to prevent CSRF from leaked URLs.
func TestAdminToken_CSRF_MutationRejectsQueryParam(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()

// POST /api/admin/runtime/gc with query-param token — must 401.
body := bytes.NewBufferString("{}")
req, _ := http.NewRequest("POST", srv.URL+"/api/admin/runtime/gc?admin_token=op-secret", body)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST /api/admin/runtime/gc (query-param): %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query-param token on POST got %d, want 401", resp.StatusCode)
}

// POST with proper X-Admin-Token header — must succeed.
req2, _ := http.NewRequest("POST", srv.URL+"/api/admin/runtime/gc", body)
req2.Header.Set("X-Admin-Token", "op-secret")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("POST /api/admin/runtime/gc (header): %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("header token on POST got %d, want 200", resp2.StatusCode)
}
}
Loading