Let a valid bootstrap token authorize initial setup from any origin

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.
This commit is contained in:
rcourtman 2026-05-10 22:25:34 +01:00
parent 9d4fdedf9a
commit 20df3dcd2c
3 changed files with 109 additions and 73 deletions

View file

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

View file

@ -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 <container> /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 <container> /app/pulse bootstrap-token\n" +
"Bare metal: pulse bootstrap-token"

View file

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