mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
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:
parent
9d4fdedf9a
commit
20df3dcd2c
3 changed files with 109 additions and 73 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue