Skip to content
Closed
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
55 changes: 55 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,57 @@ func TestStatsAuth_UnconfiguredLocksShut(t *testing.T) {
t.Errorf("unconfigured: status %d, want 401", resp.StatusCode)
}
}

// TestRequireAdminToken_RejectsQueryParamOnWrite confirms that
// requireAdminToken rejects admin_token= query-param for POST/PUT/DELETE,
// preventing CSRF and referer leaks from a leaked URL. The token must
// be sent via X-Admin-Token header for mutating requests.
func TestRequireAdminToken_RejectsQueryParamOnWrite(t *testing.T) {
t.Parallel()
cb := minimalCallbacks()
cb.GetAdminToken = func() string { return "op-secret" }
h := NewHandler(cb)

hit := false
wrapped := h.requireAdminToken(func(w http.ResponseWriter, r *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
})

cases := []struct {
name string
method string
}{
{"POST", http.MethodPost},
{"PUT", http.MethodPut},
{"DELETE", http.MethodDelete},
}
for _, tc := range cases {
t.Run(tc.method, func(t *testing.T) {
hit = false
req := httptest.NewRequest(tc.method, "/?admin_token=op-secret", bytes.NewReader(nil))
rec := httptest.NewRecorder()
wrapped(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s with query-param token: status %d, want 401", tc.method, rec.Code)
}
if hit {
t.Errorf("%s with query-param token: next handler was called unexpectedly", tc.method)
}
})
}

// Confirm GET still accepts query-param token (read-only convenience).
t.Run("GET", func(t *testing.T) {
hit = false
req := httptest.NewRequest(http.MethodGet, "/?admin_token=op-secret", nil)
rec := httptest.NewRecorder()
wrapped(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET with query-param token: status %d, want 200", rec.Code)
}
if !hit {
t.Errorf("GET with query-param token: next handler was not called")
}
})
}
Loading