diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 070bde106..376f77340 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e75d046af..12ee836c2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index dee8040a3..37cc9dc77 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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, diff --git a/internal/api/bootstrap_token.go b/internal/api/bootstrap_token.go index 0916df257..2a418d619 100644 --- a/internal/api/bootstrap_token.go +++ b/internal/api/bootstrap_token.go @@ -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 +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 04b954527..0ac875f3a 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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() diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 91dc3b0b6..e415fae83 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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. diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 8792ef458..c5910ee8f 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -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 diff --git a/internal/api/oidc_service.go b/internal/api/oidc_service.go index eb7fa9fca..1d1872cf7 100644 --- a/internal/api/oidc_service.go +++ b/internal/api/oidc_service.go @@ -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 { diff --git a/internal/api/oidc_service_additional_test.go b/internal/api/oidc_service_additional_test.go index 6bfbe891a..5f0efcd59 100644 --- a/internal/api/oidc_service_additional_test.go +++ b/internal/api/oidc_service_additional_test.go @@ -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() diff --git a/internal/api/router.go b/internal/api/router.go index f6955c4f3..367269d12 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index 9d99d281b..ed779c9f1 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -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()