diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 3c9fd7d2d..605169ed5 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1099,6 +1099,11 @@ That same transport boundary also keeps plaintext Pulse URLs loopback-only. must still use HTTPS/WSS. `InsecureSkipVerify` may relax certificate verification on TLS transport; it must not reopen plaintext HTTP for private-network updater, websocket, or reporting paths. +That same first-run lifecycle boundary also keeps unauthenticated setup local. +Lifecycle-adjacent quick setup or recovery entrypoints may exist before an +operator has configured auth, but they must stay direct-loopback only and any +recovery token/session path must stay bound to the generating localhost client +instead of reopening auth for all loopback callers. That same shared `internal/api/` lifecycle boundary also assumes tenant-scoped resource helpers stay on canonical unified-resource seeds: adjacent fleet and install surfaces may not revive raw tenant `StateSnapshot` fallback through diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 047f90b1c..ea47a9934 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -218,6 +218,10 @@ the canonical monitored-system blocked payload. canonical invitation, membership-management, or explicit owner-transfer flows may create tenant membership or change the stored owner/admin role. Shared auth routes and downstream settings consumers must treat handoff role claims as bounded by the server-owned membership record, never as authority to elevate tenant privileges. + That same shared auth boundary also owns pre-auth local setup and recovery containment. When no authentication is + configured, anonymous fallback and bootstrap quick setup may run only on direct loopback, recovery tokens must bind + to the generating client IP, and recovery may mint only a browser-bound localhost session rather than a shared + filesystem toggle that disables auth for every loopback client. The same shared auth boundary also owns release-build admin bypass gating. `internal/api/auth.go` may keep `ALLOW_ADMIN_BYPASS` for non-release development workflows, but release builds must compile that env override @@ -2874,6 +2878,11 @@ is considered, so hosted protected routes such as relay-mobile token minting, onboarding reads, and billing-admin/API surfaces stay reachable after cloud handoff instead of flattening the operator back to `anonymous` or demanding a bearer token from the browser as soon as the tenant has minted one. +That same shared auth contract also governs unauthenticated local recovery and +bootstrap ingress: before auth exists, anonymous fallback and `/api/security/quick-setup` +must remain direct-loopback only, and recovery tokens may authorize only the +same loopback client IP that minted them when establishing a browser recovery +session. That same shared settings-scope contract must then preserve canonical org-management privilege on the tenant side: when a hosted or multi-tenant request is scoped to a non-default org, `internal/api/security_setup_fix.go` diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 89db6126b..74d06ccab 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -149,25 +149,26 @@ regression protection. 1. Add performance budgets through SLO or contract tests 2. Add query-plan guardrails for DB-backed hot paths 3. Optimize hot paths only when backed by benchmarks or proven query issues -4. Extend dashboard hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Dashboard/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Dashboard/Dashboard.tsx` -5. Normalize dashboard workload view-mode aliases through `frontend-modern/src/utils/workloads.ts` instead of keeping local URL/storage parsing in `frontend-modern/src/components/Dashboard/Dashboard.tsx` -6. Deduplicate dashboard workload rows by canonical workload ID from `frontend-modern/src/utils/workloads.ts` rather than via local pass-through wrappers in `frontend-modern/src/components/Dashboard/Dashboard.tsx` -7. Render dashboard row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract -8. Format infrastructure sensor labels through the shared `frontend-modern/src/utils/textPresentation.ts` presentation helper instead of maintaining a local title-casing implementation in `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` -9. Extend dashboard row contract and per-row hot-path derivations through `frontend-modern/src/components/Dashboard/guestRowModel.tsx` and `frontend-modern/src/components/Dashboard/useGuestRowState.ts`, and extend tooltip-backed row cell presentation through `frontend-modern/src/components/Dashboard/GuestRowCells.tsx`, rather than rebuilding column metadata, row identity, cell tooltips, or anomaly correlation inside `frontend-modern/src/components/Dashboard/GuestRow.tsx` -10. Extend dashboard drawer derivations and runtime wiring through `frontend-modern/src/components/Dashboard/guestDrawerModel.ts` and `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Dashboard/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Dashboard/GuestDrawer.tsx` -11. Extend dashboard disk-list derivations and fallback runtime wiring through `frontend-modern/src/components/Dashboard/diskListModel.ts` and `frontend-modern/src/components/Dashboard/useDiskListState.ts` rather than rebuilding usage math, progress-state mapping, or tooltip fallback logic inside `frontend-modern/src/components/Dashboard/DiskList.tsx` -12. Extend dashboard guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` rather than rebuilding dashboard-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` -13. Extend dashboard deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Dashboard/dashboardSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`; canonical typed workload IDs such as `app-container::` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes -14. Extend dashboard workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts`, `frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadFilterConfigModel.ts`, and `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts` and `frontend-modern/src/components/Dashboard/dashboardWorkloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` -15. Extend grouped dashboard workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` -16. Extend dashboard control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Dashboard/useDashboardControlsState.ts` and `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` -17. Extend dashboard filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` and `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Dashboard/DashboardFilter.tsx` -18. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Dashboard/thresholdSliderModel.ts` and `frontend-modern/src/components/Dashboard/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Dashboard/ThresholdSlider.tsx` -19. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` -20. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx` -21. Extend metric-bar width, label-fit logic, and resize-observer runtime through `frontend-modern/src/components/Dashboard/metricBarModel.ts` and `frontend-modern/src/components/Dashboard/useMetricBarState.ts` rather than rebuilding metric-local state and threshold mapping inside `frontend-modern/src/components/Dashboard/MetricBar.tsx` -22. Extend enhanced CPU bar usage/anomaly presentation and tooltip runtime through `frontend-modern/src/components/Dashboard/enhancedCpuBarModel.ts` and `frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts` rather than rebuilding tooltip-local state and CPU-threshold formatting inside `frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx` +4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path. +5. Extend dashboard hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Dashboard/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Dashboard/Dashboard.tsx` +6. Normalize dashboard workload view-mode aliases through `frontend-modern/src/utils/workloads.ts` instead of keeping local URL/storage parsing in `frontend-modern/src/components/Dashboard/Dashboard.tsx` +7. Deduplicate dashboard workload rows by canonical workload ID from `frontend-modern/src/utils/workloads.ts` rather than via local pass-through wrappers in `frontend-modern/src/components/Dashboard/Dashboard.tsx` +8. Render dashboard row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract +9. Format infrastructure sensor labels through the shared `frontend-modern/src/utils/textPresentation.ts` presentation helper instead of maintaining a local title-casing implementation in `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` +10. Extend dashboard row contract and per-row hot-path derivations through `frontend-modern/src/components/Dashboard/guestRowModel.tsx` and `frontend-modern/src/components/Dashboard/useGuestRowState.ts`, and extend tooltip-backed row cell presentation through `frontend-modern/src/components/Dashboard/GuestRowCells.tsx`, rather than rebuilding column metadata, row identity, cell tooltips, or anomaly correlation inside `frontend-modern/src/components/Dashboard/GuestRow.tsx` +11. Extend dashboard drawer derivations and runtime wiring through `frontend-modern/src/components/Dashboard/guestDrawerModel.ts` and `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Dashboard/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Dashboard/GuestDrawer.tsx` +12. Extend dashboard disk-list derivations and fallback runtime wiring through `frontend-modern/src/components/Dashboard/diskListModel.ts` and `frontend-modern/src/components/Dashboard/useDiskListState.ts` rather than rebuilding usage math, progress-state mapping, or tooltip fallback logic inside `frontend-modern/src/components/Dashboard/DiskList.tsx` +13. Extend dashboard guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Dashboard/useDashboardGuestMetadataState.ts` rather than rebuilding dashboard-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` +14. Extend dashboard deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Dashboard/dashboardSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Dashboard/useDashboardState.ts`; canonical typed workload IDs such as `app-container::` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes +15. Extend dashboard workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Dashboard/useDashboardWorkloadRouteState.ts`, `frontend-modern/src/components/Dashboard/useDashboardWorkloadFilterOptions.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteModel.ts`, `frontend-modern/src/components/Dashboard/dashboardWorkloadFilterConfigModel.ts`, and `frontend-modern/src/components/Dashboard/dashboardWorkloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Dashboard/useDashboardWorkloadUrlSync.ts` and `frontend-modern/src/components/Dashboard/dashboardWorkloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` +16. Extend grouped dashboard workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` +17. Extend dashboard control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Dashboard/useDashboardControlsState.ts` and `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Dashboard/useDashboardState.ts` +18. Extend dashboard filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Dashboard/dashboardFilterModel.ts` and `frontend-modern/src/components/Dashboard/useDashboardFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Dashboard/DashboardFilter.tsx` +19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Dashboard/thresholdSliderModel.ts` and `frontend-modern/src/components/Dashboard/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Dashboard/ThresholdSlider.tsx` +20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedDiskBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` +21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Dashboard/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Dashboard/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx` +22. Extend metric-bar width, label-fit logic, and resize-observer runtime through `frontend-modern/src/components/Dashboard/metricBarModel.ts` and `frontend-modern/src/components/Dashboard/useMetricBarState.ts` rather than rebuilding metric-local state and threshold mapping inside `frontend-modern/src/components/Dashboard/MetricBar.tsx` +23. Extend enhanced CPU bar usage/anomaly presentation and tooltip runtime through `frontend-modern/src/components/Dashboard/enhancedCpuBarModel.ts` and `frontend-modern/src/components/Dashboard/useEnhancedCPUBarState.ts` rather than rebuilding tooltip-local state and CPU-threshold formatting inside `frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx` 23. Extend grouped dashboard row windowing, reveal-index clamping, overscan math, and per-group visible-slice derivation through `frontend-modern/src/components/Dashboard/useGroupedTableWindowing.ts`, and extend viewport event wiring through `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts` rather than rebuilding scroll handlers, mounted-row budgets, viewport listeners, or group-slice math inside `frontend-modern/src/components/Dashboard/useDashboardWorkloadDerivedState.ts` 24. Extend dashboard shell rendering through `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`, `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`, and `frontend-modern/src/components/Dashboard/DashboardStatsStrip.tsx` rather than accreting loading cards, workload table markup, or stats-strip presentation back into `frontend-modern/src/components/Dashboard/Dashboard.tsx` 25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx` diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index ea58aa6c9..0e040866c 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -249,6 +249,12 @@ persistence: `recovery_tokens.go` may mint raw recovery secrets for immediate operator use, but persisted `recovery_tokens.json` state must store only token hashes and treat any legacy plaintext-token file as a one-time migration input that is rewritten immediately into hashed canonical persistence on load. +That same recovery trust boundary also governs live use of those secrets: +recovery tokens must bind to the generating client IP, may authorize only a +direct-loopback browser recovery session, and must not reopen authentication +through a shared `.auth_recovery` flag that affects every localhost client. +Secret-bearing comparisons on adjacent auth paths such as metrics bearer +validation and local-auth username matching must stay constant-time. That same persistence rule also governs API token metadata: even though `api_tokens.json` stores hashed records rather than raw token secrets, a legacy plaintext metadata file may only serve as migration input. Canonical diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 48a00b72f..30b3f7db7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -74,6 +74,12 @@ querying, and the operator-facing storage health presentation layer. provider-backed recovery: storage and recovery must treat `truenas_disabled` as an explicit platform opt-out, not as the baseline onboarding state for a supported platform. + That same adjacent API boundary also owns pre-auth local recovery + containment. Storage- and recovery-adjacent quick setup or break-glass + routes may exist before auth is configured, but they must stay + direct-loopback only, keep recovery-token validation bound to the + generating client IP, and mint or clear browser recovery sessions instead + of toggling a shared `.auth_recovery` file for every localhost caller. That same adjacent API boundary also owns monitored-system admission preview transport for provider-backed setup context. `/api/truenas/connections/preview`, `/api/truenas/connections/{id}/preview`, `/api/vmware/connections/preview`, @@ -2219,6 +2225,12 @@ surfaces may run without local auth configured, but a valid tenant the anonymous optional-auth fallback so hosted recovery, onboarding, and support flows do not silently degrade into unauthenticated state or bearer- token-only mode after cloud handoff. +That same recovery-adjacent auth boundary also owns local bootstrap and +break-glass containment. Before auth is configured, quick setup and recovery +ingress must stay direct-loopback only, recovery-token validation must remain +bound to the generating client IP, and break-glass recovery must clear or mint +browser sessions rather than toggling a shared `.auth_recovery` file for every +localhost caller. That same shared `internal/api/` boundary also owns hosted AI bootstrap continuity. Storage- and recovery-adjacent hosted flows may surface Patrol- backed investigation or AI-assisted recovery guidance before an operator has diff --git a/internal/api/ai_handlers_investigation_additional_test.go b/internal/api/ai_handlers_investigation_additional_test.go index 7e8ddf1bd..b22d7fd4c 100644 --- a/internal/api/ai_handlers_investigation_additional_test.go +++ b/internal/api/ai_handlers_investigation_additional_test.go @@ -418,7 +418,7 @@ func TestHandleClearAllFindings(t *testing.T) { Description: "desc", }) - req := httptest.NewRequest(http.MethodDelete, "/api/ai/patrol/findings?confirm=true", nil) + req := newLoopbackRequest(http.MethodDelete, "/api/ai/patrol/findings?confirm=true", nil) rec := httptest.NewRecorder() handler.HandleClearAllFindings(rec, req) @@ -457,7 +457,7 @@ func TestHandlePatrolAutonomyGet(t *testing.T) { handler := newTestAISettingsHandler(cfg, persistence, nil) - getReq := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/autonomy", nil) + getReq := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/autonomy", nil) getRec := httptest.NewRecorder() handler.HandleGetPatrolAutonomy(getRec, getReq) @@ -501,7 +501,7 @@ func TestHandleGetInvestigation(t *testing.T) { orchestrator := &stubInvestigationOrchestrator{session: session} patrol.SetInvestigationOrchestrator(orchestrator) - req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/finding-1/investigation", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/findings/finding-1/investigation", nil) rec := httptest.NewRecorder() handler.HandleGetInvestigation(rec, req) @@ -555,7 +555,7 @@ func TestHandleGetInvestigationMessages(t *testing.T) { orchestrator := &stubInvestigationOrchestrator{session: session} patrol.SetInvestigationOrchestrator(orchestrator) - req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/finding-1/investigation/messages", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/findings/finding-1/investigation/messages", nil) rec := httptest.NewRecorder() handler.HandleGetInvestigationMessages(rec, req) diff --git a/internal/api/ai_handlers_oauth_test.go b/internal/api/ai_handlers_oauth_test.go index 0d694a034..1475137fb 100644 --- a/internal/api/ai_handlers_oauth_test.go +++ b/internal/api/ai_handlers_oauth_test.go @@ -359,6 +359,7 @@ func TestHandleOAuthDisconnect_AuthFailure(t *testing.T) { // With nil config, CheckAuth returns 503 Service Unavailable. handler := &AISettingsHandler{} req := httptest.NewRequest(http.MethodPost, "/api/ai/oauth/disconnect", nil) + req.RemoteAddr = "127.0.0.1:12345" rr := httptest.NewRecorder() handler.HandleOAuthDisconnect(rr, req) @@ -390,6 +391,7 @@ func TestHandleOAuthDisconnect_Success(t *testing.T) { handler.SetConfig(&config.Config{}) req := httptest.NewRequest(http.MethodPost, "/api/ai/oauth/disconnect", nil) + req.RemoteAddr = "127.0.0.1:12345" rr := httptest.NewRecorder() handler.HandleOAuthDisconnect(rr, req) @@ -431,6 +433,7 @@ func TestHandleOAuthDisconnect_NilPersistence(t *testing.T) { handler.SetConfig(&config.Config{}) req := httptest.NewRequest(http.MethodPost, "/api/ai/oauth/disconnect", nil) + req.RemoteAddr = "127.0.0.1:12345" rr := httptest.NewRecorder() handler.HandleOAuthDisconnect(rr, req) @@ -471,6 +474,7 @@ func TestHandleOAuthDisconnect_TenantIsolation(t *testing.T) { // Disconnect tenant-a's OAuth. req := httptest.NewRequest(http.MethodPost, "/api/ai/oauth/disconnect", nil) + req.RemoteAddr = "127.0.0.1:12345" req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "tenant-a")) rr := httptest.NewRecorder() diff --git a/internal/api/ai_handlers_patrol_actions_additional_test.go b/internal/api/ai_handlers_patrol_actions_additional_test.go index 5bb39e6ca..f7a9585ac 100644 --- a/internal/api/ai_handlers_patrol_actions_additional_test.go +++ b/internal/api/ai_handlers_patrol_actions_additional_test.go @@ -147,7 +147,7 @@ func TestHandleGetPatrolStatus_IncludesQuickstartFields(t *testing.T) { handler.defaultAIService.SetQuickstartCredits(&stubQuickstartCreditManager{remaining: 7}) setUnexportedField(t, handler.defaultAIService, "usingQuickstart", true) - req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/status", nil) rec := httptest.NewRecorder() handler.HandleGetPatrolStatus(rec, req) @@ -177,7 +177,7 @@ func TestHandleGetPatrolStatus_DerivesBlockedRuntimeStateForExhaustedQuickstartC handler.defaultAIService.SetQuickstartCredits(&stubQuickstartCreditManager{remaining: 0}) - req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/status", nil) rec := httptest.NewRecorder() handler.HandleGetPatrolStatus(rec, req) @@ -210,7 +210,7 @@ func TestHandleGetPatrolStatus_DistinguishesLastFullPatrolFromLastActivity(t *te setUnexportedField(t, patrol, "lastFullPatrol", lastPatrolAt) setUnexportedField(t, patrol, "lastActivity", lastActivityAt) - req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/status", nil) rec := httptest.NewRecorder() handler.HandleGetPatrolStatus(rec, req) @@ -240,7 +240,7 @@ func TestHandleGetPatrolStatus_ExposesScopedTriggerStatus(t *testing.T) { AnomalyTriggersEnabled: true, }) - req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/status", nil) rec := httptest.NewRecorder() handler.HandleGetPatrolStatus(rec, req) @@ -348,7 +348,7 @@ func TestPatrolActionHandlers_NoAIService_ReturnStructuredServiceUnavailable(t * for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req := newLoopbackRequest(tc.method, tc.path, strings.NewReader(tc.body)) rec := httptest.NewRecorder() tc.handler(rec, req) @@ -378,7 +378,7 @@ func TestHandleAcknowledgeFinding_PatrolAndUnified(t *testing.T) { addPatrolFinding(t, patrol, "finding-ack", detectedAt) addUnifiedFinding(unifiedStore, "finding-ack", detectedAt) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/acknowledge", strings.NewReader(`{"finding_id":"finding-ack"}`)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/acknowledge", strings.NewReader(`{"finding_id":"finding-ack"}`)) rec := httptest.NewRecorder() handler.HandleAcknowledgeFinding(rec, req) @@ -409,7 +409,7 @@ func TestHandleAcknowledgeFinding_UnifiedOnly(t *testing.T) { detectedAt := time.Now().Add(-1 * time.Hour) addUnifiedFinding(unifiedStore, "finding-unified-only", detectedAt) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/acknowledge", strings.NewReader(`{"finding_id":"finding-unified-only"}`)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/acknowledge", strings.NewReader(`{"finding_id":"finding-unified-only"}`)) rec := httptest.NewRecorder() handler.HandleAcknowledgeFinding(rec, req) @@ -441,7 +441,7 @@ func TestHandleSnoozeFinding_CapsDuration(t *testing.T) { addUnifiedFinding(unifiedStore, "finding-snooze", detectedAt) body := `{"finding_id":"finding-snooze","duration_hours":200}` - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/snooze", strings.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/snooze", strings.NewReader(body)) rec := httptest.NewRecorder() handler.HandleSnoozeFinding(rec, req) @@ -485,7 +485,7 @@ func TestHandleResolveFinding_SetsResolved(t *testing.T) { addPatrolFinding(t, patrol, "finding-resolve", detectedAt) addUnifiedFinding(unifiedStore, "finding-resolve", detectedAt) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/resolve", strings.NewReader(`{"finding_id":"finding-resolve"}`)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/resolve", strings.NewReader(`{"finding_id":"finding-resolve"}`)) rec := httptest.NewRecorder() handler.HandleResolveFinding(rec, req) @@ -523,7 +523,7 @@ func TestHandleDismissFinding_ValidReason(t *testing.T) { "note": "known load test", } body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/dismiss", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/dismiss", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleDismissFinding(rec, req) @@ -561,7 +561,7 @@ func TestHandleSuppressFinding_SetsSuppressed(t *testing.T) { addPatrolFinding(t, patrol, "finding-suppress", detectedAt) addUnifiedFinding(unifiedStore, "finding-suppress", detectedAt) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/suppress", strings.NewReader(`{"finding_id":"finding-suppress"}`)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/suppress", strings.NewReader(`{"finding_id":"finding-suppress"}`)) rec := httptest.NewRecorder() handler.HandleSuppressFinding(rec, req) @@ -645,7 +645,7 @@ func TestHandlePatrolRuntimeFindingActions_AreRejected(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.body)) + req := newLoopbackRequest(http.MethodPost, tt.path, strings.NewReader(tt.body)) rec := httptest.NewRecorder() tt.handler(rec, req) @@ -711,7 +711,7 @@ func TestHandleGetFindingsHistory_StartTimeFilter(t *testing.T) { addPatrolFinding(t, patrol, "finding-recent", recentTime) startTime := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) - req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/history?start_time="+startTime, nil) + req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/history?start_time="+startTime, nil) rec := httptest.NewRecorder() handler.HandleGetFindingsHistory(rec, req) @@ -739,7 +739,7 @@ func TestHandleForcePatrol_ConfigDisabled(t *testing.T) { cfg.Enabled = false patrol.SetConfig(cfg) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/run", nil) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", nil) rec := httptest.NewRecorder() handler.HandleForcePatrol(rec, req) @@ -758,7 +758,7 @@ func TestHandleForcePatrol_CommunityTierIgnoresRecentScopedActivityForFullPatrol setUnexportedField(t, patrol, "lastActivity", time.Now().Add(-10*time.Minute)) - req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/run", nil) + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", nil) rec := httptest.NewRecorder() handler.HandleForcePatrol(rec, req) diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index ebfa39b35..8754c4733 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -393,7 +393,7 @@ func TestAISettingsHandler_UpdateSettings_QuickstartRequiresActivationBeforeEnab body, _ := json.Marshal(AISettingsUpdateRequest{ Enabled: ptr(true), }) - req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleUpdateAISettings(rec, req) @@ -426,7 +426,7 @@ func TestAISettingsHandler_UpdateSettings_QuickstartBootstrapBeforeEnableUsesAct body, _ := json.Marshal(AISettingsUpdateRequest{ Enabled: ptr(true), }) - req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleUpdateAISettings(rec, req) @@ -1813,7 +1813,7 @@ func TestHandleRunCommand_RequiresApprovalID(t *testing.T) { handler := newTestAISettingsHandler(cfg, persistence, nil) body := []byte(`{"command":"uptime","target_type":"vm","target_id":"vm-101","run_on_host":false}`) - req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleRunCommand(rec, req) @@ -1847,7 +1847,7 @@ func TestHandleRunCommand_ConsumesApproval(t *testing.T) { require.NoError(t, err) body := []byte(`{"approval_id":"approval-1","command":"uptime","target_type":"vm","target_id":"vm-101","run_on_host":false}`) - req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleRunCommand(rec, req) @@ -1883,7 +1883,7 @@ func TestHandleRunCommand_RejectsCommandMismatch(t *testing.T) { require.NoError(t, err) body := []byte(`{"approval_id":"approval-2","command":"whoami","target_type":"vm","target_id":"vm-101","run_on_host":false}`) - req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleRunCommand(rec, req) @@ -1919,7 +1919,7 @@ func TestHandleRunCommand_RejectsUnsupportedTargetType(t *testing.T) { require.NoError(t, err) body := []byte(`{"approval_id":"approval-docker-1","command":"docker restart web","target_type":"docker","target_id":"host-1:web","run_on_host":false}`) - req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleRunCommand(rec, req) @@ -1958,7 +1958,7 @@ func TestHandleRunCommand_RejectsCrossOrgApproval(t *testing.T) { require.NoError(t, err) body := []byte(`{"approval_id":"approval-org-a","command":"uptime","target_type":"vm","target_id":"vm-101","run_on_host":false}`) - req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) + req := newLoopbackRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader(body)) ctx := context.WithValue(req.Context(), OrgIDContextKey, "org-b") req = req.WithContext(ctx) diff --git a/internal/api/auth.go b/internal/api/auth.go index b35135cf4..af727b10f 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -373,6 +373,22 @@ func ValidateAndExtendSession(token string) bool { return GetSessionStore().ValidateAndExtendSession(token) } +func constantTimeStringEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func requestMatchesRecoverySession(r *http.Request, session *SessionData) bool { + if r == nil || session == nil || !session.RecoveryBypass || !isDirectLoopbackRequest(r) { + return false + } + expectedIP := normalizeRecoveryBindingIP(session.IP) + actualIP := normalizeRecoveryBindingIP(GetClientIP(r)) + return expectedIP != "" && actualIP != "" && constantTimeStringEqual(expectedIP, actualIP) +} + func explicitAPITokenFromRequest(r *http.Request) (string, bool) { if r == nil { return "", false @@ -562,10 +578,23 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool // other browser-session flows must stay authoritative even when the runtime // also has API tokens configured. if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" { - // Use ValidateAndExtendSession for sliding expiration - if ValidateAndExtendSession(cookie.Value) { + session := GetSessionStore().GetSession(cookie.Value) + if session != nil && session.RecoveryBypass { + if requestMatchesRecoverySession(r, session) && ValidateAndExtendSession(cookie.Value) { + if session.Username != "" { + w.Header().Set("X-Authenticated-User", session.Username) + } + w.Header().Set("X-Auth-Method", "recovery_session") + w.Header().Set("X-Auth-Recovery", "true") + return true + } + log.Warn(). + Str("path", r.URL.Path). + Str("client_ip", GetClientIP(r)). + Str("session_ip", session.IP). + Msg("Rejected recovery session outside direct loopback binding") + } else if ValidateAndExtendSession(cookie.Value) { username := GetSessionUsername(cookie.Value) - session := GetSessionStore().GetSession(cookie.Value) if session != nil && session.OIDCRefreshToken != "" && hasEnabledSSOProvidersForAuth(cfg) { // Check if access token is expired or about to expire (5 min buffer) if time.Now().Add(5 * time.Minute).After(session.OIDCAccessTokenExp) { @@ -615,12 +644,18 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool if hasEnabledSSOProvidersForAuth(cfg) { log.Debug().Msg("SSO enabled without local credentials, authentication required") } else { - log.Debug().Msg("No auth configured, allowing access as 'anonymous'") - if w != nil { - w.Header().Set("X-Authenticated-User", "anonymous") - w.Header().Set("X-Auth-Method", "none") + if isDirectLoopbackRequest(r) { + log.Debug().Msg("No auth configured, allowing loopback access as 'anonymous'") + if w != nil { + w.Header().Set("X-Authenticated-User", "anonymous") + w.Header().Set("X-Auth-Method", "none") + } + return true } - return true + log.Warn(). + Str("path", r.URL.Path). + Str("ip", GetClientIP(r)). + Msg("Rejected non-loopback access before auth was configured") } } @@ -685,7 +720,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool return false } // Check username - userMatch := parts[0] == cfg.AuthUser + userMatch := constantTimeStringEqual(parts[0], cfg.AuthUser) // Check password - support both hashed and plain text for migration // Config always has hashed password now (auto-hashed on load) diff --git a/internal/api/auth_helpers_test.go b/internal/api/auth_helpers_test.go index 7a3ef8f79..712113302 100644 --- a/internal/api/auth_helpers_test.go +++ b/internal/api/auth_helpers_test.go @@ -956,11 +956,23 @@ func TestCheckAuth_NoAuthConfigured(t *testing.T) { // No auth configured at all } req := httptest.NewRequest("GET", "/api/test", nil) + req.RemoteAddr = "127.0.0.1:12345" w := httptest.NewRecorder() result := CheckAuth(cfg, w, req) - // Should return true when no auth is configured + // Should return true for direct loopback when no auth is configured if !result { - t.Error("CheckAuth should return true when no auth is configured") + t.Error("CheckAuth should return true for loopback when no auth is configured") + } +} + +func TestCheckAuth_NoAuthConfiguredRejectsRemote(t *testing.T) { + cfg := &config.Config{} + req := httptest.NewRequest("GET", "/api/test", nil) + req.RemoteAddr = "198.51.100.10:12345" + w := httptest.NewRecorder() + + if CheckAuth(cfg, w, req) { + t.Fatal("CheckAuth should reject remote access before auth is configured") } } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 101714af8..9870c6c8d 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -7850,6 +7850,9 @@ func TestContract_BootstrapTokenPersistenceJSONSnapshot(t *testing.T) { } func TestContract_QuickSecuritySetupBootstrapRetrievalGuidance(t *testing.T) { + resetPersistentAuthStoresForTests() + t.Cleanup(resetPersistentAuthStoresForTests) + tempDir := t.TempDir() cfg := &config.Config{ DataPath: tempDir, @@ -7860,14 +7863,15 @@ func TestContract_QuickSecuritySetupBootstrapRetrievalGuidance(t *testing.T) { persistence: config.NewConfigPersistence(cfg.DataPath), } router.initializeBootstrapToken() + InitPersistentAuthStores(tempDir) handler := handleQuickSecuritySetupFixed(router) body := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}` req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) - req.RemoteAddr = "198.51.100.40:54321" + req.RemoteAddr = "127.0.0.1:54321" rec := httptest.NewRecorder() - authLimiter.Reset("198.51.100.40") + authLimiter.Reset("127.0.0.1") handler(rec, req) if rec.Code != http.StatusUnauthorized { @@ -7881,6 +7885,69 @@ func TestContract_QuickSecuritySetupBootstrapRetrievalGuidance(t *testing.T) { } } +func TestContract_QuickSecuritySetupValidBootstrapTokenRemainsLoopbackOnly(t *testing.T) { + resetPersistentAuthStoresForTests() + t.Cleanup(resetPersistentAuthStoresForTests) + + tempDir := t.TempDir() + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + } + router := &Router{ + config: cfg, + persistence: config.NewConfigPersistence(cfg.DataPath), + } + router.initializeBootstrapToken() + InitPersistentAuthStores(tempDir) + + bootstrapToken, _, _, err := loadOrCreateBootstrapToken(tempDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken: %v", err) + } + + handler := handleQuickSecuritySetupFixed(router) + body := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}` + + remoteReq := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + remoteReq.RemoteAddr = "198.51.100.41:54321" + remoteReq.Header.Set(bootstrapTokenHeader, bootstrapToken) + remoteRec := httptest.NewRecorder() + + authLimiter.Reset("198.51.100.41") + handler(remoteRec, remoteReq) + + if remoteRec.Code != http.StatusForbidden { + t.Fatalf("remote quick setup status = %d, want 403 (%s)", remoteRec.Code, remoteRec.Body.String()) + } + if got := remoteRec.Body.String(); !strings.Contains(strings.ToLower(got), "localhost") { + t.Fatalf("remote quick setup guidance = %q, want localhost-only message", got) + } + + loopbackReq := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + loopbackReq.RemoteAddr = "127.0.0.1:54321" + loopbackReq.Header.Set(bootstrapTokenHeader, bootstrapToken) + loopbackRec := httptest.NewRecorder() + + authLimiter.Reset("127.0.0.1") + handler(loopbackRec, loopbackReq) + + if loopbackRec.Code != http.StatusOK { + t.Fatalf("loopback quick setup status = %d, want 200 (%s)", loopbackRec.Code, loopbackRec.Body.String()) + } + + foundSessionCookie := false + for _, cookie := range loopbackRec.Result().Cookies() { + if cookie.Name == cookieNameSession || cookie.Name == cookieNameSessionSecure { + foundSessionCookie = true + break + } + } + if !foundSessionCookie { + t.Fatal("expected loopback quick setup to issue a session cookie") + } +} + func TestContract_ResetFirstRunSecurityResponseJSONSnapshot(t *testing.T) { t.Setenv("PULSE_DEV", "true") t.Setenv("NODE_ENV", "") diff --git a/internal/api/loopback_request_test.go b/internal/api/loopback_request_test.go new file mode 100644 index 000000000..9f9a02dd7 --- /dev/null +++ b/internal/api/loopback_request_test.go @@ -0,0 +1,13 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" +) + +func newLoopbackRequest(method, target string, body io.Reader) *http.Request { + req := httptest.NewRequest(method, target, body) + req.RemoteAddr = "127.0.0.1:12345" + return req +} diff --git a/internal/api/persistent_auth_stores_test.go b/internal/api/persistent_auth_stores_test.go index ec0cb7520..a435a810f 100644 --- a/internal/api/persistent_auth_stores_test.go +++ b/internal/api/persistent_auth_stores_test.go @@ -52,12 +52,12 @@ func TestPersistentAuthStoresReconfigureDataPath(t *testing.T) { } InitRecoveryTokenStore(dirOne) - recoveryToken, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Hour) + recoveryToken, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Hour, "127.0.0.1") if err != nil { t.Fatalf("generate recovery token: %v", err) } InitRecoveryTokenStore(dirTwo) - if GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken) { + if GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken, "127.0.0.1") { t.Fatal("reconfigured recovery token store should not retain tokens from the previous data path") } } diff --git a/internal/api/rbac_admin_handlers_test.go b/internal/api/rbac_admin_handlers_test.go index d9b84e736..364124cfc 100644 --- a/internal/api/rbac_admin_handlers_test.go +++ b/internal/api/rbac_admin_handlers_test.go @@ -132,7 +132,7 @@ func TestResetAdminRoleEndpoint_ValidToken(t *testing.T) { InitRecoveryTokenStore(baseDir) store := GetRecoveryTokenStore() - token, err := store.GenerateRecoveryToken(5 * time.Minute) + token, err := store.GenerateRecoveryToken(5*time.Minute, "127.0.0.1") if err != nil { t.Fatalf("failed to generate recovery token: %v", err) } @@ -145,6 +145,7 @@ func TestResetAdminRoleEndpoint_ValidToken(t *testing.T) { "recovery_token": token, }) req := httptest.NewRequest(http.MethodPost, "/api/admin/rbac/reset-admin", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:12345" req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "test-org")) rec := httptest.NewRecorder() handlers.HandleRBACAdminReset(rec, req) diff --git a/internal/api/recovery_tokens.go b/internal/api/recovery_tokens.go index 4b9bd060b..8f06de668 100644 --- a/internal/api/recovery_tokens.go +++ b/internal/api/recovery_tokens.go @@ -3,8 +3,10 @@ package api import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" + "net" "os" "path/filepath" "strings" @@ -53,6 +55,34 @@ func recoveryTokenHash(token string) string { return hex.EncodeToString(sum[:]) } +func normalizeRecoveryBindingIP(ip string) string { + trimmed := strings.TrimSpace(ip) + if trimmed == "" { + return "" + } + parsed := net.ParseIP(trimmed) + if parsed == nil { + return trimmed + } + if parsed.IsLoopback() { + return "loopback" + } + return parsed.String() +} + +func recoveryTokenBoundToIP(token *RecoveryToken, ip string) bool { + if token == nil { + return false + } + expectedIP := normalizeRecoveryBindingIP(token.IP) + actualIP := normalizeRecoveryBindingIP(ip) + if expectedIP == "" || actualIP == "" { + return false + } + return len(expectedIP) == len(actualIP) && + subtle.ConstantTimeCompare([]byte(expectedIP), []byte(actualIP)) == 1 +} + // InitRecoveryTokenStore initializes the recovery token store func InitRecoveryTokenStore(dataPath string) { _ = ensureRecoveryTokenStore(dataPath) @@ -100,8 +130,8 @@ func GetRecoveryTokenStore() *RecoveryTokenStore { return store } -// GenerateRecoveryToken creates a new recovery token -func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration) (string, error) { +// GenerateRecoveryToken creates a new recovery token bound to the generating client IP. +func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration, ip string) (string, error) { // Generate secure random token tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { @@ -119,6 +149,10 @@ func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration) (stri CreatedAt: time.Now(), ExpiresAt: time.Now().Add(duration), Used: false, + IP: normalizeRecoveryBindingIP(ip), + } + if token.IP == "" { + return "", os.ErrInvalid } r.tokens[tokenHash] = token @@ -126,6 +160,7 @@ func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration) (stri log.Info(). Str("token", safePrefixForLog(tokenStr, 8)+"..."). + Str("ip", token.IP). Time("expires", token.ExpiresAt). Msg("Recovery token generated") @@ -134,7 +169,7 @@ func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration) (stri // IsRecoveryTokenValidConstantTime checks token validity without consuming it. // This is intended for preflight decisions (e.g., CSRF skip routing). -func (r *RecoveryTokenStore) IsRecoveryTokenValidConstantTime(providedToken string) bool { +func (r *RecoveryTokenStore) IsRecoveryTokenValidConstantTime(providedToken string, ip string) bool { r.mu.RLock() defer r.mu.RUnlock() @@ -142,7 +177,7 @@ func (r *RecoveryTokenStore) IsRecoveryTokenValidConstantTime(providedToken stri if !exists { return false } - return !time.Now().After(token.ExpiresAt) && !token.Used + return !time.Now().After(token.ExpiresAt) && !token.Used && recoveryTokenBoundToIP(token, ip) } // ValidateRecoveryTokenConstantTime validates token with constant-time comparison @@ -153,7 +188,7 @@ func (r *RecoveryTokenStore) ValidateRecoveryTokenConstantTime(providedToken str defer r.mu.RUnlock() token, exists := r.tokens[tokenHash] - if !exists || time.Now().After(token.ExpiresAt) || token.Used { + if !exists || time.Now().After(token.ExpiresAt) || token.Used || !recoveryTokenBoundToIP(token, ip) { return false } @@ -161,7 +196,7 @@ func (r *RecoveryTokenStore) ValidateRecoveryTokenConstantTime(providedToken str r.mu.Lock() token, exists = r.tokens[tokenHash] - if !exists || time.Now().After(token.ExpiresAt) || token.Used { + if !exists || time.Now().After(token.ExpiresAt) || token.Used || !recoveryTokenBoundToIP(token, ip) { r.mu.Unlock() r.mu.RLock() return false @@ -169,7 +204,6 @@ func (r *RecoveryTokenStore) ValidateRecoveryTokenConstantTime(providedToken str token.Used = true token.UsedAt = time.Now() - token.IP = ip r.saveUnsafe() r.mu.Unlock() r.mu.RLock() diff --git a/internal/api/recovery_tokens_test.go b/internal/api/recovery_tokens_test.go index fd1092217..fa82b93ee 100644 --- a/internal/api/recovery_tokens_test.go +++ b/internal/api/recovery_tokens_test.go @@ -10,6 +10,8 @@ import ( "time" ) +const recoveryBindIP = "10.0.0.1" + func TestRecoveryToken_Fields(t *testing.T) { now := time.Now() expiry := now.Add(time.Hour) @@ -57,7 +59,7 @@ func newTestRecoveryStore(t *testing.T) *RecoveryTokenStore { func TestRecoveryTokenStore_GenerateRecoveryToken(t *testing.T) { store := newTestRecoveryStore(t) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } @@ -93,7 +95,7 @@ func TestRecoveryTokenStore_GenerateRecoveryToken_Uniqueness(t *testing.T) { tokens := make(map[string]bool) for i := 0; i < 100; i++ { - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed on iteration %d: %v", i, err) } @@ -121,7 +123,7 @@ func TestRecoveryTokenStore_GenerateRecoveryToken_ExpiryDurations(t *testing.T) store := newTestRecoveryStore(t) beforeGen := time.Now() - token, err := store.GenerateRecoveryToken(tc.duration) + token, err := store.GenerateRecoveryToken(tc.duration, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } @@ -146,13 +148,13 @@ func TestRecoveryTokenStore_GenerateRecoveryToken_ExpiryDurations(t *testing.T) func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ValidToken(t *testing.T) { store := newTestRecoveryStore(t) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } // Validate token - if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") { + if !store.ValidateRecoveryTokenConstantTime(token, recoveryBindIP) { t.Error("ValidateRecoveryTokenConstantTime returned false for valid token") } @@ -164,23 +166,39 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ValidToken(t *test if !stored.Used { t.Error("token should be marked as used after validation") } - if stored.IP != "10.0.0.1" { - t.Errorf("IP = %q, want 10.0.0.1", stored.IP) + if stored.IP != recoveryBindIP { + t.Errorf("IP = %q, want %s", stored.IP, recoveryBindIP) } if stored.UsedAt.IsZero() { t.Error("UsedAt should be set") } } -func TestRecoveryTokenStore_IsRecoveryTokenValidConstantTime_DoesNotConsume(t *testing.T) { +func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_RejectsDifferentIP(t *testing.T) { store := newTestRecoveryStore(t) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } - if !store.IsRecoveryTokenValidConstantTime(token) { + if store.ValidateRecoveryTokenConstantTime(token, "10.0.0.2") { + t.Fatal("ValidateRecoveryTokenConstantTime should reject a different client IP") + } + if store.IsRecoveryTokenValidConstantTime(token, "10.0.0.2") { + t.Fatal("IsRecoveryTokenValidConstantTime should reject a different client IP") + } +} + +func TestRecoveryTokenStore_IsRecoveryTokenValidConstantTime_DoesNotConsume(t *testing.T) { + store := newTestRecoveryStore(t) + + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) + if err != nil { + t.Fatalf("GenerateRecoveryToken failed: %v", err) + } + + if !store.IsRecoveryTokenValidConstantTime(token, recoveryBindIP) { t.Fatal("IsRecoveryTokenValidConstantTime returned false for valid token") } @@ -191,7 +209,7 @@ func TestRecoveryTokenStore_IsRecoveryTokenValidConstantTime_DoesNotConsume(t *t t.Fatal("IsRecoveryTokenValidConstantTime must not mark token as used") } - if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") { + if !store.ValidateRecoveryTokenConstantTime(token, recoveryBindIP) { t.Fatal("ValidateRecoveryTokenConstantTime should still succeed after non-consuming check") } } @@ -199,18 +217,18 @@ func TestRecoveryTokenStore_IsRecoveryTokenValidConstantTime_DoesNotConsume(t *t func TestRecoveryTokenStore_IsRecoveryTokenValidConstantTime_InvalidOrUsed(t *testing.T) { store := newTestRecoveryStore(t) - if store.IsRecoveryTokenValidConstantTime("missing-token") { + if store.IsRecoveryTokenValidConstantTime("missing-token", recoveryBindIP) { t.Fatal("IsRecoveryTokenValidConstantTime returned true for missing token") } - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } - if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") { + if !store.ValidateRecoveryTokenConstantTime(token, recoveryBindIP) { t.Fatal("ValidateRecoveryTokenConstantTime should succeed") } - if store.IsRecoveryTokenValidConstantTime(token) { + if store.IsRecoveryTokenValidConstantTime(token, recoveryBindIP) { t.Fatal("IsRecoveryTokenValidConstantTime returned true for used token") } } @@ -219,12 +237,12 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_InvalidToken(t *te store := newTestRecoveryStore(t) // Generate a valid token but try to validate with a different one - _, err := store.GenerateRecoveryToken(time.Hour) + _, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } - if store.ValidateRecoveryTokenConstantTime("nonexistent-token", "10.0.0.1") { + if store.ValidateRecoveryTokenConstantTime("nonexistent-token", recoveryBindIP) { t.Error("ValidateRecoveryTokenConstantTime returned true for invalid token") } } @@ -244,7 +262,7 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ExpiredToken(t *te } store.mu.Unlock() - if store.ValidateRecoveryTokenConstantTime(expiredToken, "10.0.0.1") { + if store.ValidateRecoveryTokenConstantTime(expiredToken, recoveryBindIP) { t.Error("ValidateRecoveryTokenConstantTime returned true for expired token") } } @@ -252,13 +270,13 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ExpiredToken(t *te func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_UsedToken(t *testing.T) { store := newTestRecoveryStore(t) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } // Use the token - if !store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") { + if !store.ValidateRecoveryTokenConstantTime(token, recoveryBindIP) { t.Fatal("first validation should succeed") } @@ -271,7 +289,7 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_UsedToken(t *testi func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_EmptyStore(t *testing.T) { store := newTestRecoveryStore(t) - if store.ValidateRecoveryTokenConstantTime("any-token", "10.0.0.1") { + if store.ValidateRecoveryTokenConstantTime("any-token", recoveryBindIP) { t.Error("ValidateRecoveryTokenConstantTime returned true on empty store") } } @@ -279,7 +297,7 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_EmptyStore(t *test func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ConcurrentUse(t *testing.T) { store := newTestRecoveryStore(t) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } @@ -293,7 +311,7 @@ func TestRecoveryTokenStore_ValidateRecoveryTokenConstantTime_ConcurrentUse(t *t wg.Add(1) go func(id int) { defer wg.Done() - result := store.ValidateRecoveryTokenConstantTime(token, "10.0.0.1") + result := store.ValidateRecoveryTokenConstantTime(token, recoveryBindIP) results <- result }(i) } @@ -436,7 +454,7 @@ func TestRecoveryTokenStore_Persistence_Save(t *testing.T) { } // Generate a token (which triggers save) - token, err := store.GenerateRecoveryToken(time.Hour) + token, err := store.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } @@ -476,7 +494,7 @@ func TestRecoveryTokenStore_Persistence_Load(t *testing.T) { stopCleanup: make(chan struct{}), } - token, err := store1.GenerateRecoveryToken(time.Hour) + token, err := store1.GenerateRecoveryToken(time.Hour, recoveryBindIP) if err != nil { t.Fatalf("GenerateRecoveryToken failed: %v", err) } @@ -510,7 +528,8 @@ func TestRecoveryTokenStore_Load_MigratesLegacyFormat(t *testing.T) { "token": "` + rawToken + `", "created_at": "2026-03-15T12:00:00Z", "expires_at": "2099-03-15T13:00:00Z", - "used": false + "used": false, + "ip": "` + recoveryBindIP + `" } }` tokensFile := filepath.Join(tmpDir, "recovery_tokens.json") @@ -525,7 +544,7 @@ func TestRecoveryTokenStore_Load_MigratesLegacyFormat(t *testing.T) { } store.load() - if !store.IsRecoveryTokenValidConstantTime(rawToken) { + if !store.IsRecoveryTokenValidConstantTime(rawToken, recoveryBindIP) { t.Fatal("legacy recovery token should validate after migration load") } diff --git a/internal/api/router.go b/internal/api/router.go index 2a7da1d74..dad219674 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3447,29 +3447,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { needsAuth := true clientIP := GetClientIP(req) - // Recovery mechanism: Check if recovery mode is enabled - recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") - if _, err := os.Stat(recoveryFile); err == nil { - // Recovery mode is enabled - allow local access only - log.Debug(). - Str("recovery_file", recoveryFile). - Str("client_ip", clientIP). - Str("remote_addr", req.RemoteAddr). - Str("path", req.URL.Path). - Bool("file_exists", err == nil). - Msg("Checking auth recovery mode") - if isDirectLoopbackRequest(req) { - log.Warn(). - Str("recovery_file", recoveryFile). - Str("client_ip", clientIP). - Msg("AUTH RECOVERY MODE: Allowing local access without authentication") - // Allow access but add a warning header - w.Header().Set("X-Auth-Recovery", "true") - // Recovery mode bypasses auth for localhost - needsAuth = false - } - } - if needsAuth { // Normal authentication check // Normalize path to handle double slashes (e.g., //download -> /download) @@ -3480,6 +3457,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { publicPaths := []string{ "/api/health", "/api/security/status", + "/api/security/recovery", "/api/security/validate-bootstrap-token", "/api/security/quick-setup", // Handler does its own auth (bootstrap token or session) "/api/version", @@ -3622,7 +3600,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { })() validRecoveryToken := false if recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token")); recoveryToken != "" { - validRecoveryToken = GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken) + validRecoveryToken = GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken, clientIP) } if req.URL.Path == "/api/security/quick-setup" && (!authConfigured || validRecoveryToken) { @@ -4030,7 +4008,7 @@ func canCapturePublicURL(cfg *config.Config, req *http.Request) bool { adminUser := strings.TrimSpace(cfg.AuthUser) if adminUser != "" { username := strings.TrimSpace(GetSessionUsername(cookie.Value)) - if strings.EqualFold(username, adminUser) { + if constantTimeStringEqual(strings.ToLower(username), strings.ToLower(adminUser)) { return true } } @@ -4043,7 +4021,7 @@ func canCapturePublicURL(cfg *config.Config, req *http.Request) bool { if authHeader := req.Header.Get("Authorization"); strings.HasPrefix(authHeader, prefix) { if decoded, err := base64.StdEncoding.DecodeString(authHeader[len(prefix):]); err == nil { if parts := strings.SplitN(string(decoded), ":", 2); len(parts) == 2 { - if parts[0] == cfg.AuthUser && internalauth.CheckPasswordHash(parts[1], cfg.AuthPass) { + if constantTimeStringEqual(parts[0], cfg.AuthUser) && internalauth.CheckPasswordHash(parts[1], cfg.AuthPass) { return true } } @@ -4226,7 +4204,7 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request) parts := strings.SplitN(string(decoded), ":", 2) if len(parts) == 2 { // Check if this looks like Pulse credentials (matching username) - if parts[0] == r.config.AuthUser { + if constantTimeStringEqual(parts[0], r.config.AuthUser) { // This is likely from Pulse's own auth, not a proxy username = parts[0] useAuthHeader = true @@ -4409,36 +4387,7 @@ func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) { return } - // Get session token from cookie - var sessionToken string - if cookie, err := readSessionCookie(req); err == nil { - sessionToken = cookie.Value - } - - // Delete the session if it exists - if sessionToken != "" { - GetSessionStore().DeleteSession(sessionToken) - - // Also delete CSRF token if exists - GetCSRFStore().DeleteCSRFToken(sessionToken) - } - - // Get appropriate cookie settings based on proxy detection (consistent with login) - isSecure, sameSitePolicy := getCookieSettings(req) - - // Clear both session cookie variants (prefixed and unprefixed) to ensure - // a clean logout regardless of how the cookie was originally set. - for _, name := range []string{cookieNameSession, cookieNameSessionSecure} { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: isSecure, - SameSite: sameSitePolicy, - }) - } + r.clearSession(w, req) // Audit log logout (use admin as username since we have single user for now) LogAuditEventForTenant(GetOrgID(req.Context()), "logout", "admin", GetClientIP(req), req.URL.Path, true, "User logged out") @@ -4498,6 +4447,47 @@ func (r *Router) establishSession(w http.ResponseWriter, req *http.Request, user return nil } +func (r *Router) establishRecoverySession(w http.ResponseWriter, req *http.Request, username string) error { + InvalidateOldSessionFromRequest(req) + + token := generateSessionToken() + if token == "" { + return fmt.Errorf("failed to generate recovery session token") + } + + userAgent := req.Header.Get("User-Agent") + clientIP := normalizeRecoveryBindingIP(GetClientIP(req)) + if clientIP == "" { + return fmt.Errorf("recovery session requires a client IP") + } + + GetSessionStore().CreateRecoverySession(token, 24*time.Hour, userAgent, clientIP, username) + + csrfToken := generateCSRFToken(token) + isSecure, sameSitePolicy := getCookieSettings(req) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName(isSecure), + Value: token, + Path: "/", + HttpOnly: true, + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, + }) + + http.SetCookie(w, &http.Cookie{ + Name: CookieNameCSRF, + Value: csrfToken, + Path: "/", + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, + }) + + return nil +} + // establishOIDCSession creates a session with OIDC token information for refresh token support func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username string, oidcTokens *OIDCTokenInfo) error { // Invalidate any pre-existing session to prevent session fixation attacks. @@ -4607,7 +4597,7 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) { } // Verify credentials - if loginReq.Username == r.config.AuthUser && auth.CheckPasswordHash(loginReq.Password, r.config.AuthPass) { + if constantTimeStringEqual(loginReq.Username, r.config.AuthUser) && auth.CheckPasswordHash(loginReq.Password, r.config.AuthPass) { // Clear failed login attempts ClearFailedLogins(loginReq.Username) ClearFailedLogins(clientIP) diff --git a/internal/api/router_csrf_middleware_test.go b/internal/api/router_csrf_middleware_test.go index baf75902e..6908d6d6b 100644 --- a/internal/api/router_csrf_middleware_test.go +++ b/internal/api/router_csrf_middleware_test.go @@ -18,6 +18,7 @@ func newRouterWithSession(t *testing.T) (*Router, string) { dir := t.TempDir() InitSessionStore(dir) InitCSRFStore(dir) + InitRecoveryTokenStore(dir) hashed, err := internalauth.HashPassword("Password!1") if err != nil { @@ -221,12 +222,13 @@ func TestRouterCSRFBypassedForQuickSetupWithValidRecoveryToken(t *testing.T) { w.WriteHeader(http.StatusOK) }) - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute) + token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5*time.Minute, "127.0.0.1") if err != nil { t.Fatalf("generate recovery token: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", nil) + req.RemoteAddr = "127.0.0.1:12345" req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) req.Header.Set("X-Recovery-Token", token) rec := httptest.NewRecorder() diff --git a/internal/api/router_recovery_test.go b/internal/api/router_recovery_test.go index e926fb35a..8c73398bf 100644 --- a/internal/api/router_recovery_test.go +++ b/internal/api/router_recovery_test.go @@ -2,11 +2,8 @@ package api import ( "encoding/json" - "errors" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -33,25 +30,44 @@ func newRecoveryRouter(t *testing.T) *Router { ConfigPath: dir, } - router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") - - recoveryFile := filepath.Join(cfg.DataPath, ".auth_recovery") - if err := os.WriteFile(recoveryFile, []byte("recovery enabled"), 0600); err != nil { - t.Fatalf("write recovery file: %v", err) - } - - return router + return NewRouter(cfg, nil, nil, nil, nil, "1.0.0") } -func TestAuthRecoveryAllowsDirectLoopback(t *testing.T) { +func establishLoopbackRecoverySession(t *testing.T, router *Router) *http.Cookie { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"disable_auth"}`)) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String()) + } + + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == cookieNameSession || cookie.Name == cookieNameSessionSecure { + return cookie + } + } + + t.Fatal("expected recovery session cookie") + return nil +} + +func TestRecoverySessionAllowsDirectLoopback(t *testing.T) { router := newRecoveryRouter(t) router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + sessionCookie := establishLoopbackRecoverySession(t, router) + req := httptest.NewRequest(http.MethodGet, "/api/secure", nil) req.RemoteAddr = "127.0.0.1:12345" + req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -64,16 +80,19 @@ func TestAuthRecoveryAllowsDirectLoopback(t *testing.T) { } } -func TestAuthRecoveryRejectsForwardedLoopback(t *testing.T) { +func TestRecoverySessionRejectsForwardedLoopback(t *testing.T) { router := newRecoveryRouter(t) router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + sessionCookie := establishLoopbackRecoverySession(t, router) + req := httptest.NewRequest(http.MethodGet, "/api/secure", nil) req.RemoteAddr = "127.0.0.1:12345" req.Header.Set("X-Forwarded-For", "127.0.0.1") + req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -111,12 +130,22 @@ func TestRecoveryEndpointDisableAuthAllowsLoopback(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String()) } + foundSessionCookie := false + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == cookieNameSession || cookie.Name == cookieNameSessionSecure { + foundSessionCookie = true + break + } + } + if !foundSessionCookie { + t.Fatal("expected recovery session cookie") + } } -func TestRecoveryEndpointDisableAuthAllowsValidToken(t *testing.T) { +func TestRecoveryEndpointDisableAuthRejectsTokenFromDifferentIP(t *testing.T) { router := newRecoveryRouter(t) InitRecoveryTokenStore(router.config.DataPath) - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute) + token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5*time.Minute, "127.0.0.1") if err != nil { t.Fatalf("generate recovery token: %v", err) } @@ -129,23 +158,23 @@ func TestRecoveryEndpointDisableAuthAllowsValidToken(t *testing.T) { router.mux.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String()) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String()) } } -func TestRecoveryEndpointEnableAuthRemovesFile(t *testing.T) { +func TestRecoveryEndpointEnableAuthClearsRecoverySession(t *testing.T) { router := newRecoveryRouter(t) - InitRecoveryTokenStore(router.config.DataPath) - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute) - if err != nil { - t.Fatalf("generate recovery token: %v", err) - } + router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + sessionCookie := establishLoopbackRecoverySession(t, router) req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"enable_auth"}`)) - req.RemoteAddr = "203.0.113.52:12345" + req.RemoteAddr = "127.0.0.1:12345" req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Recovery-Token", token) + req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.mux.ServeHTTP(rec, req) @@ -154,9 +183,14 @@ func TestRecoveryEndpointEnableAuthRemovesFile(t *testing.T) { t.Fatalf("expected status %d, got %d (%s)", http.StatusOK, rec.Code, rec.Body.String()) } - recoveryFile := filepath.Join(router.config.DataPath, ".auth_recovery") - if _, err := os.Stat(recoveryFile); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("expected recovery file to be removed, got err=%v", err) + followUp := httptest.NewRequest(http.MethodGet, "/api/secure", nil) + followUp.RemoteAddr = "127.0.0.1:12345" + followUp.AddCookie(sessionCookie) + followRec := httptest.NewRecorder() + + router.ServeHTTP(followRec, followUp) + if followRec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d after clearing recovery session, got %d (%s)", http.StatusUnauthorized, followRec.Code, followRec.Body.String()) } } @@ -181,7 +215,7 @@ func TestRecoveryEndpointGenerateTokenRejectsRemoteToken(t *testing.T) { router := newRecoveryRouter(t) resetRecoveryStore() InitRecoveryTokenStore(router.config.DataPath) - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute) + token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5*time.Minute, "127.0.0.1") if err != nil { t.Fatalf("generate recovery token: %v", err) } diff --git a/internal/api/router_routes_auth_security.go b/internal/api/router_routes_auth_security.go index 7c7f77321..92c49c61f 100644 --- a/internal/api/router_routes_auth_security.go +++ b/internal/api/router_routes_auth_security.go @@ -426,11 +426,11 @@ func (r *Router) registerAuthSecurityInstallRoutes() { return } - // Write a recovery flag file before restarting + // Retire the legacy filesystem recovery toggle before restart. Recovery + // remains available through the localhost-bound recovery session flow. recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") - recoveryContent := fmt.Sprintf("Auth setup at %s\nIf locked out, delete this file and restart to disable auth temporarily\n", time.Now().Format(time.RFC3339)) - if err := os.WriteFile(recoveryFile, []byte(recoveryContent), 0600); err != nil { - log.Warn().Err(err).Str("path", recoveryFile).Msg("Failed to write recovery flag file") + if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) { + log.Warn().Err(err).Str("path", recoveryFile).Msg("Failed to remove legacy recovery flag file") } // Schedule restart with full service restart to pick up new config @@ -521,7 +521,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() { duration = recoveryRequest.Duration } - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Duration(duration) * time.Minute) + token, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Duration(duration)*time.Minute, clientIP) if err != nil { log.Error().Err(err).Msg("Failed to generate recovery token") response["success"] = false @@ -539,34 +539,39 @@ func (r *Router) registerAuthSecurityInstallRoutes() { } case "disable_auth": - // Temporarily disable auth by creating recovery file - recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") - content := fmt.Sprintf("Recovery mode enabled at %s\nAuth temporarily disabled for local access\nEnabled by: %s\n", time.Now().Format(time.RFC3339), clientIP) - if err := os.WriteFile(recoveryFile, []byte(content), 0600); err != nil { - log.Error().Err(err).Msg("Failed to enable recovery mode") + recoveryUser := strings.TrimSpace(r.config.AuthUser) + if recoveryUser == "" { + recoveryUser = "recovery" + } + if err := r.establishRecoverySession(w, req, recoveryUser); err != nil { + log.Error().Err(err).Msg("Failed to establish recovery session") response["success"] = false response["message"] = "Failed to enable recovery mode" } else { + recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") + if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) { + log.Warn().Err(err).Str("path", recoveryFile).Msg("Failed to remove legacy recovery flag file") + } response["success"] = true - response["message"] = "Recovery mode enabled. Auth disabled for localhost. Delete .auth_recovery file to re-enable." + response["message"] = "Recovery mode enabled for this local browser session only." log.Warn(). Str("ip", clientIP). Bool("direct_loopback", isLoopback). Bool("via_token", hasValidToken). - Msg("AUTH RECOVERY: Authentication disabled via recovery endpoint") + Msg("AUTH RECOVERY: Recovery session established") } case "enable_auth": - // Re-enable auth by removing recovery file + r.clearSession(w, req) recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") - if err := os.Remove(recoveryFile); err != nil { + if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) { log.Error().Err(err).Msg("Failed to disable recovery mode") response["success"] = false response["message"] = "Failed to disable recovery mode" } else { response["success"] = true - response["message"] = "Recovery mode disabled. Authentication re-enabled." - log.Info().Msg("AUTH RECOVERY: Authentication re-enabled via recovery endpoint") + response["message"] = "Recovery mode disabled for this browser session." + log.Info().Msg("AUTH RECOVERY: Recovery session cleared via recovery endpoint") } default: @@ -577,12 +582,15 @@ func (r *Router) registerAuthSecurityInstallRoutes() { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } else if req.Method == http.MethodGet { - // Check recovery status - recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") - _, err := os.Stat(recoveryFile) + recoveryMode := false + if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) { + if session := GetSessionStore().GetSession(cookie.Value); requestMatchesRecoverySession(req, session) { + recoveryMode = true + } + } response := map[string]interface{}{ - "recovery_mode": err == nil, - "message": "Recovery endpoint accessible from localhost only", + "recovery_mode": recoveryMode, + "message": "Recovery endpoint accessible from localhost only; recovery sessions are browser-bound", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 4813c4a20..37a93435e 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -218,6 +218,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } if !authorized && !authConfigured { + if !isDirectLoopbackRequest(req) { + log.Warn(). + Str("ip", clientIP). + Msg("Rejected initial quick setup outside direct loopback") + http.Error(w, "Initial security setup is only available from localhost until authentication is configured", http.StatusForbidden) + return + } + if r.bootstrapTokenHash == "" { log.Error().Msg("Bootstrap setup token unavailable; refusing unauthenticated quick setup") http.Error(w, "Bootstrap token unavailable; restart Pulse or inspect data directory", http.StatusServiceUnavailable) diff --git a/internal/api/security_setup_fix_additional_test.go b/internal/api/security_setup_fix_additional_test.go index 5ef57906a..4d79b25a7 100644 --- a/internal/api/security_setup_fix_additional_test.go +++ b/internal/api/security_setup_fix_additional_test.go @@ -313,11 +313,11 @@ func TestQuickSecuritySetupBootstrapTokenUnavailable(t *testing.T) { } handler := handleQuickSecuritySetupFixed(router) - authLimiter.Reset("198.51.100.16") + authLimiter.Reset("127.0.0.1") payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}` req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) - req.RemoteAddr = "198.51.100.16:54321" + req.RemoteAddr = "127.0.0.1:54321" rec := httptest.NewRecorder() handler(rec, req) @@ -351,11 +351,11 @@ func TestQuickSecuritySetupAcceptsSetupTokenInBody(t *testing.T) { handler := handleQuickSecuritySetupFixed(router) - authLimiter.Reset("198.51.100.17") + authLimiter.Reset("127.0.0.1") payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `","setupToken":"` + bootstrapToken + `"}` //nolint:lll req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) - req.RemoteAddr = "198.51.100.17:54321" + req.RemoteAddr = "127.0.0.1:54321" rec := httptest.NewRecorder() handler(rec, req) diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index 657fdf272..9d99d281b 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -86,12 +86,12 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { handler := handleQuickSecuritySetupFixed(router) - authLimiter.Reset("198.51.100.80") + authLimiter.Reset("127.0.0.1") payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}` req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) - req.RemoteAddr = "198.51.100.80:54321" + req.RemoteAddr = "127.0.0.1:54321" rr := httptest.NewRecorder() handler(rr, req) if rr.Code != http.StatusUnauthorized { @@ -106,11 +106,23 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { rrWith := httptest.NewRecorder() handler(rrWith, reqWith) - if rrWith.Code != http.StatusOK { - t.Fatalf("expected 200 OK with valid bootstrap token, got %d (%s)", rrWith.Code, rrWith.Body.String()) + if rrWith.Code != http.StatusForbidden { + t.Fatalf("expected 403 forbidden for remote quick setup even with valid bootstrap token, got %d (%s)", rrWith.Code, rrWith.Body.String()) } - cookies := rrWith.Result().Cookies() + authLimiter.Reset("127.0.0.1") + + reqLoopback := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) + reqLoopback.RemoteAddr = "127.0.0.1:54321" + reqLoopback.Header.Set(bootstrapTokenHeader, bootstrapToken) + + rrLoopback := httptest.NewRecorder() + handler(rrLoopback, reqLoopback) + if rrLoopback.Code != http.StatusOK { + t.Fatalf("expected 200 OK with valid bootstrap token from loopback, got %d (%s)", rrLoopback.Code, rrLoopback.Body.String()) + } + + cookies := rrLoopback.Result().Cookies() sessionCookie := findCookie(cookies, sessionCookieName(false)) if sessionCookie == nil || strings.TrimSpace(sessionCookie.Value) == "" { t.Fatalf("expected quick setup to establish a browser session cookie") @@ -230,7 +242,7 @@ func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) { } InitRecoveryTokenStore(cfg.DataPath) - token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute) + token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5*time.Minute, "127.0.0.1") if err != nil { t.Fatalf("generate recovery token: %v", err) } diff --git a/internal/api/session_store.go b/internal/api/session_store.go index e4770bc93..b4bb6dad9 100644 --- a/internal/api/session_store.go +++ b/internal/api/session_store.go @@ -33,6 +33,7 @@ func sessionHash(token string) string { type sessionPersisted struct { Key string `json:"key"` Username string `json:"username,omitempty"` + RecoveryBypass bool `json:"recovery_bypass,omitempty"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` UserAgent string `json:"user_agent,omitempty"` @@ -53,6 +54,7 @@ type sessionPersisted struct { // SessionData represents a user session type SessionData struct { Username string `json:"username,omitempty"` // The authenticated user + RecoveryBypass bool `json:"recovery_bypass,omitempty"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` UserAgent string `json:"user_agent,omitempty"` @@ -99,6 +101,7 @@ func (s *SessionStore) loadHashedSessions(persisted []sessionPersisted, now time s.sessions[entry.Key] = &SessionData{ Username: entry.Username, + RecoveryBypass: entry.RecoveryBypass, ExpiresAt: entry.ExpiresAt, CreatedAt: entry.CreatedAt, UserAgent: entry.UserAgent, @@ -208,6 +211,7 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA key := sessionHash(token) s.sessions[key] = &SessionData{ Username: username, + RecoveryBypass: false, ExpiresAt: time.Now().Add(duration), CreatedAt: time.Now(), UserAgent: userAgent, @@ -219,6 +223,26 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA s.saveUnsafe() } +// CreateRecoverySession creates a localhost-bound recovery session for the +// configured browser client without disabling auth globally. +func (s *SessionStore) CreateRecoverySession(token string, duration time.Duration, userAgent, ip, username string) { + s.mu.Lock() + defer s.mu.Unlock() + + key := sessionHash(token) + s.sessions[key] = &SessionData{ + Username: username, + RecoveryBypass: true, + ExpiresAt: time.Now().Add(duration), + CreatedAt: time.Now(), + UserAgent: userAgent, + IP: ip, + OriginalDuration: duration, + } + + s.saveUnsafe() +} + // OIDCTokenInfo contains OAuth2 token information from the IdP type OIDCTokenInfo struct { RefreshToken string @@ -235,6 +259,7 @@ func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, u key := sessionHash(token) session := &SessionData{ Username: username, + RecoveryBypass: false, ExpiresAt: time.Now().Add(duration), CreatedAt: time.Now(), UserAgent: userAgent, @@ -270,6 +295,7 @@ func (s *SessionStore) CreateSAMLSession(token string, duration time.Duration, u key := sessionHash(token) session := &SessionData{ Username: username, + RecoveryBypass: false, ExpiresAt: time.Now().Add(duration), CreatedAt: time.Now(), UserAgent: userAgent, @@ -462,6 +488,7 @@ func (s *SessionStore) saveUnsafe() { persisted = append(persisted, sessionPersisted{ Key: key, Username: session.Username, + RecoveryBypass: session.RecoveryBypass, ExpiresAt: session.ExpiresAt, CreatedAt: session.CreatedAt, UserAgent: session.UserAgent, diff --git a/internal/api/session_store_test.go b/internal/api/session_store_test.go index e13c05ba8..7e9efe9f4 100644 --- a/internal/api/session_store_test.go +++ b/internal/api/session_store_test.go @@ -162,6 +162,48 @@ func TestSessionStore_CreateAndValidate(t *testing.T) { } } +func TestSessionStore_CreateRecoverySession_PersistsRecoveryBypass(t *testing.T) { + tmpDir := t.TempDir() + + store := NewSessionStore(tmpDir) + token := "recovery-session-token" + store.CreateRecoverySession(token, time.Hour, "TestAgent", "loopback", "recovery") + store.Shutdown() + + data, err := os.ReadFile(filepath.Join(tmpDir, "sessions.json")) + if err != nil { + t.Fatalf("ReadFile sessions.json: %v", err) + } + + var persisted []sessionPersisted + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("Unmarshal persisted sessions: %v", err) + } + if len(persisted) != 1 { + t.Fatalf("expected 1 persisted session, got %d", len(persisted)) + } + if !persisted[0].RecoveryBypass { + t.Fatal("expected persisted recovery session to retain RecoveryBypass") + } + + reloaded := NewSessionStore(tmpDir) + defer reloaded.Shutdown() + + session := reloaded.GetSession(token) + if session == nil { + t.Fatal("expected reloaded recovery session to exist") + } + if !session.RecoveryBypass { + t.Fatal("expected reloaded recovery session to retain RecoveryBypass") + } + if session.Username != "recovery" { + t.Fatalf("reloaded recovery session username = %q, want %q", session.Username, "recovery") + } + if session.IP != "loopback" { + t.Fatalf("reloaded recovery session IP = %q, want %q", session.IP, "loopback") + } +} + func TestSessionStore_ValidateSession_NonExistent(t *testing.T) { store := &SessionStore{ sessions: make(map[string]*SessionData), diff --git a/pkg/server/server.go b/pkg/server/server.go index 0d61bd0ed..ec3f56703 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/subtle" "crypto/tls" "encoding/base64" "fmt" @@ -735,7 +736,12 @@ func startMetricsServer(ctx context.Context, addr string, metricsToken string) { handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") const prefix = "Bearer " - if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) || auth[len(prefix):] != metricsToken { + providedToken := "" + if len(auth) >= len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) { + providedToken = auth[len(prefix):] + } + if len(providedToken) != len(metricsToken) || + subtle.ConstantTimeCompare([]byte(providedToken), []byte(metricsToken)) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return }