Bind recovery and bootstrap auth to direct loopback

This commit is contained in:
rcourtman 2026-04-22 00:39:53 +01:00
parent 360d08104e
commit 586473ee31
27 changed files with 562 additions and 215 deletions

View file

@ -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

View file

@ -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`

View file

@ -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:<host>:<provider-id>` 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:<host>:<provider-id>` 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`

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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", "")

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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")
}

View file

@ -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)

View file

@ -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()

View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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,

View file

@ -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),

View file

@ -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
}