mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Bind recovery and bootstrap auth to direct loopback
This commit is contained in:
parent
360d08104e
commit
586473ee31
27 changed files with 562 additions and 215 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
13
internal/api/loopback_request_test.go
Normal file
13
internal/api/loopback_request_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue