mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Harden API request and bootstrap state handling
This commit is contained in:
@@ -1113,7 +1113,9 @@ rest and rewrite any legacy plaintext bootstrap-token file immediately into
|
||||
the encrypted canonical format on load. Automatic startup logs may surface the
|
||||
token file path for local recovery, but they must never print the bootstrap
|
||||
token value itself into stdout, systemd journal, Docker logs, or Kubernetes
|
||||
pod logs.
|
||||
pod logs. The validation endpoint for that same bootstrap token must also
|
||||
rate-limit per client and return an explicit `Retry-After` backoff instead of
|
||||
offering an unbounded brute-force surface during first-run setup.
|
||||
That same deploy/install runtime boundary also owns peer-node SSH trust.
|
||||
`internal/hostagent/commands_deploy.go` must resolve and persist peer host
|
||||
keys through the managed `ssh_known_hosts` store before any automated deploy
|
||||
|
||||
@@ -602,6 +602,13 @@ entry.
|
||||
|
||||
The API layer already uses contract tests in many places, but every major live
|
||||
contract should continue moving toward canonical-only runtime shapes.
|
||||
That same shared `internal/api/` boundary now also keeps ephemeral auth flow
|
||||
state and request correlation fail-closed. OIDC authorization state storage
|
||||
must cap abandoned entries and evict the earliest-expiring state before
|
||||
unbounded growth, bootstrap token validation must enforce a per-client retry
|
||||
limit with an explicit `Retry-After` contract, and incoming `X-Request-ID`
|
||||
headers may only round-trip when they fit the bounded safe character set used
|
||||
for logs and response headers.
|
||||
That same shared settings/licensing contract now also owns the split usage-data
|
||||
payload model. `frontend-modern/src/api/settings.ts`,
|
||||
`internal/api/router_routes_licensing.go`, and adjacent settings callers must
|
||||
|
||||
@@ -595,7 +595,9 @@ engine stop hiding inside broader monitoring and E2E buckets.
|
||||
That same first-session recovery boundary also treats the bootstrap token as a
|
||||
local secret, not a log artifact. Storage and recovery surfaces may surface the
|
||||
bootstrap token file path when first-run auth is missing, but automatic runtime
|
||||
logs must never print the bootstrap token value itself.
|
||||
logs must never print the bootstrap token value itself. That same recovery
|
||||
surface must also keep bootstrap token validation rate-limited per client so
|
||||
the local recovery transport does not become an unbounded online guessing path.
|
||||
Storage and recovery browser helpers now also keep one transport-tolerant
|
||||
normalization edge. Recovery display models must accept legacy subject-label
|
||||
fields and nullable mode/kind metadata before presenting canonical item labels,
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/bootstrap"
|
||||
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
@@ -107,6 +109,30 @@ func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.R
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := GetClientIP(req)
|
||||
if clientIP == "" {
|
||||
clientIP = extractRemoteIP(req.RemoteAddr)
|
||||
}
|
||||
if clientIP == "" {
|
||||
clientIP = req.RemoteAddr
|
||||
}
|
||||
|
||||
if limiter := r.bootstrapTokenLimiter(); limiter != nil {
|
||||
if allowed, retryAfter := limiter.allowAt(clientIP, time.Now()); !allowed {
|
||||
retrySeconds := int(retryAfter.Round(time.Second) / time.Second)
|
||||
if retrySeconds < 1 {
|
||||
retrySeconds = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retrySeconds))
|
||||
log.Warn().
|
||||
Str("ip", clientIP).
|
||||
Int("retry_after_seconds", retrySeconds).
|
||||
Msg("Rejected bootstrap token validation request due to rate limit")
|
||||
http.Error(w, "Too many bootstrap token validation attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.bootstrapTokenHash == "" {
|
||||
http.Error(w, "Bootstrap token unavailable. Reload the page or restart Pulse.", http.StatusConflict)
|
||||
return
|
||||
@@ -132,7 +158,7 @@ func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.R
|
||||
|
||||
if !r.bootstrapTokenValid(token) {
|
||||
log.Warn().
|
||||
Str("ip", GetClientIP(req)).
|
||||
Str("ip", clientIP).
|
||||
Msg("Rejected invalid bootstrap token validation request")
|
||||
http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -140,3 +166,13 @@ func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.R
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (r *Router) bootstrapTokenLimiter() *RateLimiter {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.bootstrapTokenValidationLimiter == nil {
|
||||
r.bootstrapTokenValidationLimiter = NewRateLimiter(10, 5*time.Minute)
|
||||
}
|
||||
return r.bootstrapTokenValidationLimiter
|
||||
}
|
||||
|
||||
@@ -11220,6 +11220,61 @@ func TestContract_SimpleStatsUsesTextNodesForContainerFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_APIRejectsUnsafeIncomingRequestIDHeader(t *testing.T) {
|
||||
handler := ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
req.Header.Set("X-Request-ID", "bad\nrequest-id")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("X-Request-ID")
|
||||
if got == "" {
|
||||
t.Fatal("expected generated request id")
|
||||
}
|
||||
if got == "bad\nrequest-id" {
|
||||
t.Fatalf("unsafe request id header must not round-trip: %q", got)
|
||||
}
|
||||
if strings.ContainsAny(got, "\r\n") {
|
||||
t.Fatalf("response request id must not contain control characters: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_BootstrapTokenValidationRateLimitsPerClient(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
router := &Router{
|
||||
config: &config.Config{
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
},
|
||||
bootstrapTokenValidationLimiter: NewRateLimiter(1, time.Hour),
|
||||
}
|
||||
t.Cleanup(router.bootstrapTokenValidationLimiter.Stop)
|
||||
router.initializeBootstrapToken()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleValidateBootstrapToken(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
rec = httptest.NewRecorder()
|
||||
router.handleValidateBootstrapToken(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusTooManyRequests, rec.Body.String())
|
||||
}
|
||||
if retryAfter := rec.Header().Get("Retry-After"); retryAfter == "" {
|
||||
t.Fatal("expected Retry-After header on bootstrap token validation rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func mustStreamEvent(t *testing.T, eventType string, data interface{}) chat.StreamEvent {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type APIError struct {
|
||||
Details map[string]string `json:"details"`
|
||||
}
|
||||
|
||||
const maxIncomingRequestIDLength = 128
|
||||
|
||||
func EmptyAPIError() APIError {
|
||||
return APIError{}.NormalizeCollections()
|
||||
}
|
||||
@@ -55,8 +57,8 @@ func ErrorHandler(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Add request ID to context, honoring any incoming header value.
|
||||
incomingID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
|
||||
// Add request ID to context, honoring only a bounded safe header value.
|
||||
incomingID := sanitizeIncomingRequestID(r.Header.Get("X-Request-ID"))
|
||||
ctxWithID, requestID := logging.WithRequestID(r.Context(), incomingID)
|
||||
r = r.WithContext(ctxWithID)
|
||||
|
||||
@@ -122,6 +124,24 @@ func writeErrorResponse(w http.ResponseWriter, statusCode int, code, message str
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeIncomingRequestID(raw string) string {
|
||||
requestID := strings.TrimSpace(raw)
|
||||
if requestID == "" || len(requestID) > maxIncomingRequestIDLength {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(requestID); i++ {
|
||||
b := requestID[i]
|
||||
if (b >= 'a' && b <= 'z') ||
|
||||
(b >= 'A' && b <= 'Z') ||
|
||||
(b >= '0' && b <= '9') ||
|
||||
b == '-' || b == '_' || b == '.' || b == ':' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return requestID
|
||||
}
|
||||
|
||||
// sanitizeErrorForClient returns a generic, safe message for an internal error.
|
||||
// The raw error is logged server-side; the client only sees the generic message.
|
||||
// Use this instead of passing err.Error() to http.Error or writeErrorResponse.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -470,6 +470,68 @@ func TestErrorHandler_PanicRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_PreservesSafeRequestIDHeader(t *testing.T) {
|
||||
handler := ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("X-Request-ID", "client.trace-123:abc")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if got := rec.Header().Get("X-Request-ID"); got != "client.trace-123:abc" {
|
||||
t.Fatalf("X-Request-ID = %q, want %q", got, "client.trace-123:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler_ReplacesUnsafeRequestIDHeader(t *testing.T) {
|
||||
handler := ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("X-Request-ID", "bad\nrequest-id")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("X-Request-ID")
|
||||
if got == "" {
|
||||
t.Fatal("expected generated request id")
|
||||
}
|
||||
if got == "bad\nrequest-id" {
|
||||
t.Fatalf("unsafe request id should not be preserved: %q", got)
|
||||
}
|
||||
if strings.ContainsAny(got, "\r\n") {
|
||||
t.Fatalf("generated request id must not contain control characters: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeIncomingRequestID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", raw: "", want: ""},
|
||||
{name: "trimmed safe value", raw: " trace-123._:abc ", want: "trace-123._:abc"},
|
||||
{name: "rejects newline", raw: "trace\n123", want: ""},
|
||||
{name: "rejects space", raw: "trace 123", want: ""},
|
||||
{name: "rejects slash", raw: "trace/123", want: ""},
|
||||
{name: "rejects overly long value", raw: strings.Repeat("a", maxIncomingRequestIDLength+1), want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeIncomingRequestID(tt.raw); got != tt.want {
|
||||
t.Fatalf("sanitizeIncomingRequestID(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter is a ResponseWriter that fails on Write
|
||||
type failingWriter struct {
|
||||
header http.Header
|
||||
|
||||
@@ -398,6 +398,11 @@ type oidcStateStore struct {
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
const (
|
||||
oidcStateCleanupInterval = 5 * time.Minute
|
||||
maxOIDCStateEntries = 1024
|
||||
)
|
||||
|
||||
type oidcStateEntry struct {
|
||||
ProviderID string // SSO provider ID (empty for legacy flow)
|
||||
Nonce string
|
||||
@@ -415,7 +420,7 @@ func newOIDCStateStore() *oidcStateStore {
|
||||
|
||||
// Start cleanup routine to prevent memory leak from abandoned OIDC flows
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
ticker := time.NewTicker(oidcStateCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
@@ -436,12 +441,7 @@ func (s *oidcStateStore) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for state, entry := range s.entries {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, state)
|
||||
}
|
||||
}
|
||||
s.cleanupExpiredLocked(time.Now())
|
||||
}
|
||||
|
||||
// Stop stops the cleanup routine
|
||||
@@ -454,7 +454,10 @@ func (s *oidcStateStore) Stop() {
|
||||
func (s *oidcStateStore) Put(state string, entry *oidcStateEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.cleanupExpiredLocked(time.Now())
|
||||
s.entries[state] = entry
|
||||
s.evictOverflowLocked(maxOIDCStateEntries)
|
||||
}
|
||||
|
||||
func (s *oidcStateStore) Consume(state string) (*oidcStateEntry, bool) {
|
||||
@@ -474,6 +477,37 @@ func (s *oidcStateStore) Consume(state string) (*oidcStateEntry, bool) {
|
||||
return entry, true
|
||||
}
|
||||
|
||||
func (s *oidcStateStore) cleanupExpiredLocked(now time.Time) {
|
||||
for state, entry := range s.entries {
|
||||
if entry == nil || now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *oidcStateStore) evictOverflowLocked(limit int) {
|
||||
for len(s.entries) > limit {
|
||||
oldestState := ""
|
||||
var oldestExpiry time.Time
|
||||
|
||||
for state, entry := range s.entries {
|
||||
expiry := time.Time{}
|
||||
if entry != nil {
|
||||
expiry = entry.ExpiresAt
|
||||
}
|
||||
if oldestState == "" || expiry.Before(oldestExpiry) || (expiry.Equal(oldestExpiry) && state < oldestState) {
|
||||
oldestState = state
|
||||
oldestExpiry = expiry
|
||||
}
|
||||
}
|
||||
|
||||
if oldestState == "" {
|
||||
return
|
||||
}
|
||||
delete(s.entries, oldestState)
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomURLString(size int) (string, error) {
|
||||
bytes := make([]byte, size)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
|
||||
@@ -170,6 +170,53 @@ func TestOIDCStateStoreCleanupAndConsume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCStateStorePutEvictsEarliestExpiryWhenAtCapacity(t *testing.T) {
|
||||
store := &oidcStateStore{entries: make(map[string]*oidcStateEntry), stopCleanup: make(chan struct{})}
|
||||
base := time.Now().Add(time.Minute)
|
||||
|
||||
for i := 0; i < maxOIDCStateEntries; i++ {
|
||||
store.Put(fmt.Sprintf("state-%04d", i), &oidcStateEntry{
|
||||
ExpiresAt: base.Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
store.Put("state-overflow", &oidcStateEntry{ExpiresAt: base.Add(24 * time.Hour)})
|
||||
|
||||
if len(store.entries) != maxOIDCStateEntries {
|
||||
t.Fatalf("entry count = %d, want %d", len(store.entries), maxOIDCStateEntries)
|
||||
}
|
||||
if _, ok := store.entries["state-0000"]; ok {
|
||||
t.Fatal("expected earliest-expiring entry to be evicted")
|
||||
}
|
||||
if _, ok := store.entries["state-overflow"]; !ok {
|
||||
t.Fatal("expected newest entry to be retained after overflow eviction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCStateStorePutDropsExpiredEntriesBeforeEvicting(t *testing.T) {
|
||||
store := &oidcStateStore{entries: make(map[string]*oidcStateEntry), stopCleanup: make(chan struct{})}
|
||||
store.entries["expired"] = &oidcStateEntry{ExpiresAt: time.Now().Add(-time.Minute)}
|
||||
|
||||
base := time.Now().Add(time.Minute)
|
||||
for i := 0; i < maxOIDCStateEntries-1; i++ {
|
||||
store.Put(fmt.Sprintf("active-%04d", i), &oidcStateEntry{
|
||||
ExpiresAt: base.Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
store.Put("active-new", &oidcStateEntry{ExpiresAt: base.Add(24 * time.Hour)})
|
||||
|
||||
if len(store.entries) != maxOIDCStateEntries {
|
||||
t.Fatalf("entry count = %d, want %d", len(store.entries), maxOIDCStateEntries)
|
||||
}
|
||||
if _, ok := store.entries["expired"]; ok {
|
||||
t.Fatal("expected expired entry to be removed before overflow eviction")
|
||||
}
|
||||
if _, ok := store.entries["active-0000"]; !ok {
|
||||
t.Fatal("expected active entry to remain when expired entry provided capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCStateStoreStop(t *testing.T) {
|
||||
store := &oidcStateStore{entries: make(map[string]*oidcStateEntry), stopCleanup: make(chan struct{})}
|
||||
store.Stop()
|
||||
|
||||
+87
-85
@@ -64,64 +64,65 @@ import (
|
||||
|
||||
// Router handles HTTP routing
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
config *config.Config
|
||||
monitor *monitoring.Monitor // Legacy/Default support
|
||||
mtMonitor *monitoring.MultiTenantMonitor // Multi-tenant manager
|
||||
alertHandlers *AlertHandlers
|
||||
configHandlers *ConfigHandlers
|
||||
trueNASHandlers *TrueNASHandlers
|
||||
vmwareHandlers *VMwareHandlers
|
||||
connectionsHandlers *ConnectionsHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
notificationQueueHandlers *NotificationQueueHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
kubernetesAgentHandlers *KubernetesAgentHandlers
|
||||
unifiedAgentHandlers *UnifiedAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
aiSettingsHandler *AISettingsHandler
|
||||
aiHandler *AIHandler // AI chat handler
|
||||
discoveryHandlers *DiscoveryHandlers
|
||||
resourceHandlers *ResourceHandlers
|
||||
resourceRegistry *unifiedresources.ResourceRegistry
|
||||
trueNASPoller *monitoring.TrueNASPoller
|
||||
vmwarePoller *monitoring.VMwarePoller
|
||||
monitorResourceAdapter *unifiedresources.MonitorAdapter
|
||||
monitorResourceAdapters map[string]*unifiedresources.MonitorAdapter
|
||||
monitorAdapterMu sync.Mutex
|
||||
monitorSupplementalRecords map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider
|
||||
reportingHandlers *ReportingHandlers
|
||||
configProfileHandler *ConfigProfileHandler
|
||||
licenseHandlers *LicenseHandlers
|
||||
recoveryHandlers *RecoveryHandlers
|
||||
rbacProvider *TenantRBACProvider
|
||||
logHandlers *LogHandlers
|
||||
agentExecServer *agentexec.Server
|
||||
deployHandlers *DeployHandlers
|
||||
deployStore *deploy.Store
|
||||
wsHub *websocket.Hub
|
||||
reloadFunc func() error
|
||||
updateManager *updates.Manager
|
||||
updateHistory *updates.UpdateHistory
|
||||
exportLimiter *RateLimiter
|
||||
downloadLimiter *RateLimiter
|
||||
signupRateLimiter *RateLimiter
|
||||
handoffExchangeRateLimiter *RateLimiter
|
||||
tenantRateLimiter *TenantRateLimiter
|
||||
persistence *config.ConfigPersistence
|
||||
multiTenant *config.MultiTenantPersistence
|
||||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
oidcManager *OIDCServiceManager
|
||||
samlManager *SAMLServiceManager
|
||||
ssoConfig *config.SSOConfig
|
||||
sessionStore *SessionStore
|
||||
csrfStore *CSRFTokenStore
|
||||
recoveryTokenStore *RecoveryTokenStore
|
||||
authorizer auth.Authorizer
|
||||
wrapped http.Handler
|
||||
serverVersion string
|
||||
projectRoot string
|
||||
mux *http.ServeMux
|
||||
config *config.Config
|
||||
monitor *monitoring.Monitor // Legacy/Default support
|
||||
mtMonitor *monitoring.MultiTenantMonitor // Multi-tenant manager
|
||||
alertHandlers *AlertHandlers
|
||||
configHandlers *ConfigHandlers
|
||||
trueNASHandlers *TrueNASHandlers
|
||||
vmwareHandlers *VMwareHandlers
|
||||
connectionsHandlers *ConnectionsHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
notificationQueueHandlers *NotificationQueueHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
kubernetesAgentHandlers *KubernetesAgentHandlers
|
||||
unifiedAgentHandlers *UnifiedAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
aiSettingsHandler *AISettingsHandler
|
||||
aiHandler *AIHandler // AI chat handler
|
||||
discoveryHandlers *DiscoveryHandlers
|
||||
resourceHandlers *ResourceHandlers
|
||||
resourceRegistry *unifiedresources.ResourceRegistry
|
||||
trueNASPoller *monitoring.TrueNASPoller
|
||||
vmwarePoller *monitoring.VMwarePoller
|
||||
monitorResourceAdapter *unifiedresources.MonitorAdapter
|
||||
monitorResourceAdapters map[string]*unifiedresources.MonitorAdapter
|
||||
monitorAdapterMu sync.Mutex
|
||||
monitorSupplementalRecords map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider
|
||||
reportingHandlers *ReportingHandlers
|
||||
configProfileHandler *ConfigProfileHandler
|
||||
licenseHandlers *LicenseHandlers
|
||||
recoveryHandlers *RecoveryHandlers
|
||||
rbacProvider *TenantRBACProvider
|
||||
logHandlers *LogHandlers
|
||||
agentExecServer *agentexec.Server
|
||||
deployHandlers *DeployHandlers
|
||||
deployStore *deploy.Store
|
||||
wsHub *websocket.Hub
|
||||
reloadFunc func() error
|
||||
updateManager *updates.Manager
|
||||
updateHistory *updates.UpdateHistory
|
||||
exportLimiter *RateLimiter
|
||||
downloadLimiter *RateLimiter
|
||||
signupRateLimiter *RateLimiter
|
||||
handoffExchangeRateLimiter *RateLimiter
|
||||
bootstrapTokenValidationLimiter *RateLimiter
|
||||
tenantRateLimiter *TenantRateLimiter
|
||||
persistence *config.ConfigPersistence
|
||||
multiTenant *config.MultiTenantPersistence
|
||||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
oidcManager *OIDCServiceManager
|
||||
samlManager *SAMLServiceManager
|
||||
ssoConfig *config.SSOConfig
|
||||
sessionStore *SessionStore
|
||||
csrfStore *CSRFTokenStore
|
||||
recoveryTokenStore *RecoveryTokenStore
|
||||
authorizer auth.Authorizer
|
||||
wrapped http.Handler
|
||||
serverVersion string
|
||||
projectRoot string
|
||||
// Cached system settings to avoid loading from disk on every request
|
||||
settingsMu sync.RWMutex
|
||||
cachedAllowEmbedding bool
|
||||
@@ -224,33 +225,34 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit
|
||||
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
|
||||
|
||||
r := &Router{
|
||||
mux: http.NewServeMux(),
|
||||
config: cfg,
|
||||
monitor: monitor,
|
||||
mtMonitor: mtMonitor,
|
||||
wsHub: wsHub,
|
||||
reloadFunc: reloadFunc,
|
||||
updateManager: updateManager,
|
||||
updateHistory: updateHistory,
|
||||
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
|
||||
downloadLimiter: NewRateLimiter(60, 1*time.Minute), // downloads/installers per minute per IP
|
||||
signupRateLimiter: NewRateLimiter(5, 1*time.Hour), // signup attempts per hour per IP
|
||||
handoffExchangeRateLimiter: NewRateLimiter(20, 1*time.Minute), // cloud handoff token exchange per minute per IP
|
||||
persistence: config.NewConfigPersistence(cfg.DataPath),
|
||||
multiTenant: config.NewMultiTenantPersistence(cfg.DataPath),
|
||||
sessionStore: sessionStore,
|
||||
csrfStore: csrfStore,
|
||||
authorizer: auth.GetAuthorizer(),
|
||||
serverVersion: strings.TrimSpace(serverVersion),
|
||||
projectRoot: projectRoot,
|
||||
checksumCache: make(map[string]checksumCacheEntry),
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
lifecycleCancel: lifecycleCancel,
|
||||
hostedMode: os.Getenv("PULSE_HOSTED_MODE") == "true",
|
||||
conversionStore: store,
|
||||
monitorResourceAdapters: make(map[string]*unifiedresources.MonitorAdapter),
|
||||
monitorSupplementalRecords: make(map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider),
|
||||
startedPatrolOrgs: make(map[string]bool),
|
||||
mux: http.NewServeMux(),
|
||||
config: cfg,
|
||||
monitor: monitor,
|
||||
mtMonitor: mtMonitor,
|
||||
wsHub: wsHub,
|
||||
reloadFunc: reloadFunc,
|
||||
updateManager: updateManager,
|
||||
updateHistory: updateHistory,
|
||||
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
|
||||
downloadLimiter: NewRateLimiter(60, 1*time.Minute), // downloads/installers per minute per IP
|
||||
signupRateLimiter: NewRateLimiter(5, 1*time.Hour), // signup attempts per hour per IP
|
||||
handoffExchangeRateLimiter: NewRateLimiter(20, 1*time.Minute), // cloud handoff token exchange per minute per IP
|
||||
bootstrapTokenValidationLimiter: NewRateLimiter(10, 5*time.Minute), // bootstrap token validation attempts per 5 minutes per IP
|
||||
persistence: config.NewConfigPersistence(cfg.DataPath),
|
||||
multiTenant: config.NewMultiTenantPersistence(cfg.DataPath),
|
||||
sessionStore: sessionStore,
|
||||
csrfStore: csrfStore,
|
||||
authorizer: auth.GetAuthorizer(),
|
||||
serverVersion: strings.TrimSpace(serverVersion),
|
||||
projectRoot: projectRoot,
|
||||
checksumCache: make(map[string]checksumCacheEntry),
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
lifecycleCancel: lifecycleCancel,
|
||||
hostedMode: os.Getenv("PULSE_HOSTED_MODE") == "true",
|
||||
conversionStore: store,
|
||||
monitorResourceAdapters: make(map[string]*unifiedresources.MonitorAdapter),
|
||||
monitorSupplementalRecords: make(map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider),
|
||||
startedPatrolOrgs: make(map[string]bool),
|
||||
}
|
||||
if r.wsHub != nil {
|
||||
r.wsHub.SetTrustedProxyChecker(isTrustedProxyIP)
|
||||
|
||||
@@ -215,6 +215,45 @@ func TestValidateBootstrapTokenEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBootstrapTokenEndpoint_RateLimited(t *testing.T) {
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
DataPath: dataDir,
|
||||
ConfigPath: dataDir,
|
||||
}
|
||||
|
||||
router := &Router{
|
||||
config: cfg,
|
||||
bootstrapTokenValidationLimiter: NewRateLimiter(1, time.Hour),
|
||||
}
|
||||
t.Cleanup(router.bootstrapTokenValidationLimiter.Stop)
|
||||
router.initializeBootstrapToken()
|
||||
|
||||
handler := http.HandlerFunc(router.handleValidateBootstrapToken)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for first invalid token, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 after exhausting bootstrap token validation limit, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After header on bootstrap token validation rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) {
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
|
||||
Reference in New Issue
Block a user