Harden API request and bootstrap state handling

This commit is contained in:
rcourtman
2026-04-22 05:42:45 +01:00
parent 178c073830
commit 583471b5ee
11 changed files with 404 additions and 98 deletions
@@ -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,
+37 -1
View File
@@ -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
}
+55
View File
@@ -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()
+22 -2
View File
@@ -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.
+63 -1
View File
@@ -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
+41 -7
View File
@@ -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
View File
@@ -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)
+39
View File
@@ -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()