mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
9a4f75b5b7
POST /api/security/quick-setup with force=true sets the administrator username and password and replaces every API token with a full-access one. With authentication already configured, a token holding only settings:write could call it, so settings:write was enough to take the instance over, although the endpoint is documented as needing a bootstrap token or an authenticated session. A token caller now needs an instance-wide full-access token (full access, bound only to the default organization) once the request would change credentials. The not-forced "already configured" answer is unchanged, and browser sessions, the bootstrap token and recovery token paths are untouched. The two existing force tests that used a settings:write caller are about persistence rollback and settings preservation, so they now use a full-access caller. The new tests use a deliberately weak password so a caller that passes the check is stopped by validation before any credential or session is written, and they fail when the check is removed. Contract-Neutral: Behavioural fix that gives credential rotation the authority check regenerate-token and token management already have; no new API surface or contract.
98 lines
3.6 KiB
Go
98 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
// Quick setup with force rotates the administrator username and password and
|
|
// replaces every API token with a full-access one. With authentication already
|
|
// configured, a token caller therefore needs instance-wide authority:
|
|
// settings:write alone must not be enough to take the instance over.
|
|
//
|
|
// The password in these requests is deliberately too weak. A caller that
|
|
// passes the authority check is then stopped by password validation with a
|
|
// 400, before any credentials are written or a session is created, so the
|
|
// tests prove which side of the authority check a caller lands on without
|
|
// touching shared session state.
|
|
func quickSetupWithTokenCaller(t *testing.T, callerScopes []string, callerOrg string) (*config.Config, *httptest.ResponseRecorder, string) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
hash, err := internalauth.HashPassword("OriginalPass!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
const rawCaller = "quick-setup-caller-token.12345678"
|
|
caller, err := config.NewAPITokenRecord(rawCaller, "caller", callerScopes)
|
|
if err != nil {
|
|
t.Fatalf("new caller record: %v", err)
|
|
}
|
|
if callerOrg != "" {
|
|
caller.OrgID = callerOrg
|
|
}
|
|
cfg := &config.Config{
|
|
DataPath: dir, ConfigPath: dir,
|
|
AuthUser: "original-admin", AuthPass: hash,
|
|
APITokens: []config.APITokenRecord{*caller},
|
|
}
|
|
cfg.SortAPITokens()
|
|
router := &Router{config: cfg, persistence: config.NewConfigPersistence(dir)}
|
|
|
|
body := `{"username":"new-admin","password":"weak","apiToken":"` + strings.Repeat("ab", 32) + `","force":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body))
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
req.Header.Set("X-API-Token", rawCaller)
|
|
rec := httptest.NewRecorder()
|
|
authLimiter.Reset("127.0.0.1")
|
|
handleQuickSecuritySetupFixed(router)(rec, req)
|
|
return cfg, rec, caller.ID
|
|
}
|
|
|
|
func TestQuickSecuritySetupRefusesTokenCallersWithoutInstanceWideAuthority(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
scopes []string
|
|
org string
|
|
}{
|
|
{"settings:write only", []string{config.ScopeSettingsWrite}, ""},
|
|
{"settings read and write", []string{config.ScopeSettingsRead, config.ScopeSettingsWrite}, ""},
|
|
{"full access bound to another organization", []string{config.ScopeWildcard}, "org-a"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg, rec, callerID := quickSetupWithTokenCaller(t, tc.scopes, tc.org)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d (%s), want 403 from the authority check", rec.Code, rec.Body.String())
|
|
}
|
|
if cfg.AuthUser != "original-admin" {
|
|
t.Fatalf("administrator username changed to %q", cfg.AuthUser)
|
|
}
|
|
if !internalauth.CheckPasswordHash("OriginalPass!1", cfg.AuthPass) {
|
|
t.Fatal("administrator password was replaced")
|
|
}
|
|
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != callerID {
|
|
t.Fatalf("token inventory changed: %+v", cfg.APITokens)
|
|
}
|
|
if _, err := os.Stat(resolveAuthEnvPath(cfg.ConfigPath)); err == nil {
|
|
t.Fatal("a refused setup still wrote the authentication file")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupLetsAnInstanceWideTokenReachValidation(t *testing.T) {
|
|
_, rec, _ := quickSetupWithTokenCaller(t, []string{config.ScopeWildcard}, "")
|
|
|
|
// Past the authority check, the weak password is rejected as a bad request.
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d (%s), want 400 from password validation", rec.Code, rec.Body.String())
|
|
}
|
|
}
|