From 20df3dcd2c4aec2eaffa77df1bef1957043e4713 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 22:25:34 +0100 Subject: [PATCH] Let a valid bootstrap token authorize initial setup from any origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loopback gate from 586473ee3 rejected non-loopback setup requests before the bootstrap-token check could run, so a Proxmox-LXC install (install script prints URL + token; user opens URL on workstation, pastes token) hit "only available from localhost" even with the correct token. The token is the security boundary — only callers with filesystem access to the data dir can read it — so a valid token now authorizes setup from any origin. No-token requests still require direct loopback. Updates the two contract/setup tests that pinned the old behavior. Fixes discussion #1459. --- internal/api/contract_test.go | 141 ++++++++++++++---------- internal/api/security_setup_fix.go | 24 ++-- internal/api/security_setup_fix_test.go | 17 +-- 3 files changed, 109 insertions(+), 73 deletions(-) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 872487309..c1dea3bf9 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -8898,67 +8898,94 @@ 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) +// A valid bootstrap token authorizes quick setup from any origin — the token +// is the security boundary, and it's only readable by callers with filesystem +// access to the Pulse data directory. The loopback-only path remains for the +// no-token fallback (legacy console flow). +func TestContract_QuickSecuritySetupValidBootstrapTokenAuthorizesAnyOrigin(t *testing.T) { 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 + newHandler := func(t *testing.T) (http.HandlerFunc, string) { + t.Helper() + tempDir := t.TempDir() + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, } + router := &Router{ + config: cfg, + persistence: config.NewConfigPersistence(cfg.DataPath), + } + router.initializeBootstrapToken() + InitPersistentAuthStores(tempDir) + + token, _, _, err := loadOrCreateBootstrapToken(tempDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken: %v", err) + } + return handleQuickSecuritySetupFixed(router), token } - if !foundSessionCookie { - t.Fatal("expected loopback quick setup to issue a session cookie") - } + + t.Run("remote IP with valid bootstrap token succeeds", func(t *testing.T) { + handler, token := newHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + req.RemoteAddr = "198.51.100.41:54321" + req.Header.Set(bootstrapTokenHeader, token) + rec := httptest.NewRecorder() + + authLimiter.Reset("198.51.100.41") + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("remote quick setup with bootstrap token status = %d, want 200 (%s)", 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 remote quick setup with bootstrap token to issue a session cookie") + } + }) + + t.Run("remote IP without bootstrap token is rejected with bootstrap guidance", func(t *testing.T) { + handler, _ := newHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + req.RemoteAddr = "198.51.100.41:54321" + rec := httptest.NewRecorder() + + authLimiter.Reset("198.51.100.41") + handler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("remote quick setup without bootstrap token status = %d, want 403 (%s)", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); !strings.Contains(strings.ToLower(got), "bootstrap") { + t.Fatalf("remote no-token rejection guidance = %q, want bootstrap-token message", got) + } + }) + + t.Run("loopback IP with valid bootstrap token succeeds", func(t *testing.T) { + handler, token := newHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + req.RemoteAddr = "127.0.0.1:54321" + req.Header.Set(bootstrapTokenHeader, token) + rec := httptest.NewRecorder() + + authLimiter.Reset("127.0.0.1") + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("loopback quick setup status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + }) } func TestContract_ResetFirstRunSecurityResponseJSONSnapshot(t *testing.T) { diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index b749d4487..ec99456d4 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -201,14 +201,6 @@ 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) @@ -220,7 +212,23 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { providedToken = strings.TrimSpace(setupRequest.SetupToken) } + // The bootstrap token is the security boundary for initial setup: + // only callers with filesystem access to the data dir can read it. + // A valid token authorizes setup from any origin so users running + // Pulse in a Proxmox LXC (the common case) can complete setup from + // their workstation browser. Without a token, only direct loopback + // can finish setup — that lane preserves the legacy console flow. if providedToken == "" { + if !isDirectLoopbackRequest(req) { + log.Warn(). + Str("ip", clientIP). + Msg("Rejected initial quick setup: no bootstrap token from non-loopback origin") + errorMsg := "Initial security setup requires the bootstrap token when accessed outside localhost. Retrieve it from the host:\n\n" + + "Docker: docker exec /app/pulse bootstrap-token\n" + + "Bare metal: pulse bootstrap-token" + http.Error(w, errorMsg, http.StatusForbidden) + return + } errorMsg := "Bootstrap setup token required. Retrieve it from the host:\n\n" + "Docker: docker exec /app/pulse bootstrap-token\n" + "Bare metal: pulse bootstrap-token" diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index ed779c9f1..a602e7199 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -77,6 +77,7 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { persistence: config.NewConfigPersistence(cfg.DataPath), } router.initializeBootstrapToken() + InitPersistentAuthStores(dataDir) tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename) bootstrapToken, _, _, err := bootstrap.Load(cfg.DataPath) @@ -98,16 +99,16 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { t.Fatalf("expected 401 unauthorized without bootstrap token, got %d (%s)", rr.Code, rr.Body.String()) } + // Remote IP without a bootstrap token is rejected — loopback is the only + // no-token fallback. authLimiter.Reset("198.51.100.80") - reqWith := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) - reqWith.RemoteAddr = "198.51.100.80:54321" - reqWith.Header.Set(bootstrapTokenHeader, bootstrapToken) - - rrWith := httptest.NewRecorder() - handler(rrWith, reqWith) - 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()) + reqRemoteNoToken := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload)) + reqRemoteNoToken.RemoteAddr = "198.51.100.80:54321" + rrRemoteNoToken := httptest.NewRecorder() + handler(rrRemoteNoToken, reqRemoteNoToken) + if rrRemoteNoToken.Code != http.StatusForbidden { + t.Fatalf("expected 403 forbidden for remote quick setup without bootstrap token, got %d (%s)", rrRemoteNoToken.Code, rrRemoteNoToken.Body.String()) } authLimiter.Reset("127.0.0.1")