3cf49c7fd5
The customer sets + owns the dashboard password via a hub-emailed one-time claim code. An unclaimed box (code hash present, no password) serves ONLY the claim page — every other route → claim page (302) or 401, so a Day-0 box is never open on the internet. A set password disables the gate (auth wins). Reset rides the same code engine (login "Elfelejtett jelszó"). Legacy-open (no password, no hash) shows a red transition banner until the hub delivers a hash. Report ACK caches the code state idempotently by generation; report carries claimed (set-only). --print-reset-code root escape hatch. Requires hub v0.50.0. Gate-coverage signature test + 4 red-proofs proven.
246 lines
9.9 KiB
Go
246 lines
9.9 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// claimTestServer builds a Server with a claim code installed (unclaimed, no password) and the
|
|
// full mux (RequireAuth+CsrfProtect wired exactly as main.go does), so route-level gating is
|
|
// exercised end-to-end. Returns the server, the plaintext code, and the settings.
|
|
func claimTestServer(t *testing.T) (*Server, string, *settings.Settings) {
|
|
t.Helper()
|
|
lg := log.New(io.Discard, "", 0)
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Customer.ID = "c1"
|
|
cfg.Customer.Name = "Teszt"
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
cfg.Stacks.ComposeCommand = "docker compose"
|
|
cfg.Web.SessionSecret = "test-session-secret-abcdef"
|
|
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatalf("settings: %v", err)
|
|
}
|
|
mgr, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatalf("stacks: %v", err)
|
|
}
|
|
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
|
|
s.loadTemplates()
|
|
|
|
code := "alma-korte-szilva"
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
|
|
if err := sett.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
t.Fatalf("SetClaimCode: %v", err)
|
|
}
|
|
return s, code, sett
|
|
}
|
|
|
|
// fullMux replicates main.go's handler composition so the gate is tested where it actually runs.
|
|
func (s *Server) fullMux() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
|
mux.Handle("/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeHTTP))))
|
|
return mux
|
|
}
|
|
|
|
// §10 THE SIGNATURE TEST — every route of an unclaimed box (with a code hash) answers the claim
|
|
// page (redirect to /claim) or a 401 JSON; NOTHING else is reachable, and a mutating POST reaches
|
|
// NO handler. Red-proof: remove the claim-gate block in RequireAuth → these assertions fail.
|
|
func TestClaimGate_EveryRouteGated(t *testing.T) {
|
|
s, _, _ := claimTestServer(t)
|
|
mux := s.fullMux()
|
|
|
|
// A representative sweep of the real route surface (pages + APIs + a mutating deploy POST).
|
|
htmlRoutes := []string{"/", "/dashboard", "/stacks", "/backups", "/monitoring", "/settings", "/settings/security", "/storage", "/apps/vaultwarden", "/import", "/debug"}
|
|
for _, p := range htmlRoutes {
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
|
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
|
|
t.Errorf("GET %s: got %d loc=%q, want 302→/claim", p, rr.Code, rr.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
apiRoutes := []string{"/api/disks", "/api/storage/x", "/api/host-metrics", "/api/backup/restore-status"}
|
|
for _, p := range apiRoutes {
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
|
if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), "not yet claimed") {
|
|
t.Errorf("GET %s: got %d body=%q, want 401 not-yet-claimed", p, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// A mutating deploy POST must be REFUSED before any handler runs (401, no side effect).
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/stacks/vaultwarden/deploy", strings.NewReader("{}")))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("POST deploy on unclaimed box: got %d, want 401 (no mutation reachable)", rr.Code)
|
|
}
|
|
|
|
// The claim page + its assets + health ARE reachable.
|
|
for _, p := range []string{"/claim", "/api/health", "/static/style.css"} {
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GET %s on unclaimed box: got %d, want 200 (allowed)", p, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The happy-path claim: correct code + password → password set, claimed, code consumed, session
|
|
// issued; a second use of the SAME code is refused (single-use via consumed generation).
|
|
func TestClaimSubmit_HappyPathThenReuseRefused(t *testing.T) {
|
|
s, code, sett := claimTestServer(t)
|
|
|
|
do := func(codeVal, pw string) *httptest.ResponseRecorder {
|
|
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {codeVal}, "new_password": {pw}, "confirm_password": {pw}}
|
|
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
|
|
rr := httptest.NewRecorder()
|
|
s.handleClaimSubmit(rr, req)
|
|
return rr
|
|
}
|
|
|
|
rr := do(code, "a-strong-passphrase-12")
|
|
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" {
|
|
t.Fatalf("claim submit: got %d loc=%q, want 302→/", rr.Code, rr.Header().Get("Location"))
|
|
}
|
|
if !sett.GetClaimed() {
|
|
t.Fatal("box not marked claimed after a successful claim")
|
|
}
|
|
if !s.authEnabled() {
|
|
t.Fatal("password not set after claim (authEnabled false)")
|
|
}
|
|
if s.claimGateActive() {
|
|
t.Fatal("gate still active after claim")
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("a-strong-passphrase-12")) != nil {
|
|
t.Fatal("stored password does not verify the chosen password")
|
|
}
|
|
// A session cookie was issued.
|
|
if len(rr.Result().Cookies()) == 0 {
|
|
t.Fatal("no session cookie issued on claim")
|
|
}
|
|
|
|
// Reuse the SAME code (generation 1, now consumed) → refused even though the hash matches.
|
|
rr = do(code, "another-strong-pass-12")
|
|
if rr.Code == http.StatusFound {
|
|
t.Fatal("consumed code was accepted again — single-use broken")
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
|
|
t.Errorf("reuse should show the wrong/expired-code error, body=%q", claimFirstLine(rr.Body.String()))
|
|
}
|
|
}
|
|
|
|
// Wrong codes lock the endpoint after 5 attempts (fake clock); the window then reopens.
|
|
func TestClaimSubmit_LockoutAndWindowReopen(t *testing.T) {
|
|
s, _, _ := claimTestServer(t)
|
|
now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
|
|
s.claimClock = func() time.Time { return now }
|
|
|
|
submitWrong := func() *httptest.ResponseRecorder {
|
|
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {"wrong-wrong-wrong"}, "new_password": {"x-really-long-pass"}, "confirm_password": {"x-really-long-pass"}}
|
|
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.RemoteAddr = "203.0.113.7:5000"
|
|
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
|
|
rr := httptest.NewRecorder()
|
|
s.handleClaimSubmit(rr, req)
|
|
return rr
|
|
}
|
|
|
|
for i := 0; i < claimMaxAttempts; i++ {
|
|
submitWrong()
|
|
}
|
|
// The 6th (post-cap) attempt is locked out.
|
|
rr := submitWrong()
|
|
if !strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
|
|
t.Fatalf("expected lockout after %d failures, body=%q", claimMaxAttempts, claimFirstLine(rr.Body.String()))
|
|
}
|
|
// Advance past the window → unlocked (a wrong code shows the normal error again, not lockout).
|
|
now = now.Add(claimLockoutWindow + time.Minute)
|
|
rr = submitWrong()
|
|
if strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
|
|
t.Fatal("still locked after the window elapsed")
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
|
|
t.Errorf("post-window wrong code should show the normal error, body=%q", claimFirstLine(rr.Body.String()))
|
|
}
|
|
}
|
|
|
|
// An expired code (issued > 72h ago) is refused.
|
|
func TestClaimSubmit_ExpiredCodeRefused(t *testing.T) {
|
|
s, code, sett := claimTestServer(t)
|
|
// Re-issue the code with an old issued_at.
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
|
|
sett.SetClaimCode(string(hash), 2, time.Now().Add(-73*time.Hour).UTC().Format(time.RFC3339))
|
|
|
|
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"}}
|
|
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
|
|
rr := httptest.NewRecorder()
|
|
s.handleClaimSubmit(rr, req)
|
|
if rr.Code == http.StatusFound {
|
|
t.Fatal("expired code was accepted")
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
|
|
t.Errorf("expected expired-code error, body=%q", claimFirstLine(rr.Body.String()))
|
|
}
|
|
}
|
|
|
|
// Legacy-open (no password, no code hash) passes through with the red banner flag; a box with a
|
|
// set password is entirely ungated (no claim page ever).
|
|
func TestClaimGate_LegacyOpenAndPasswordSet(t *testing.T) {
|
|
// Legacy-open: fresh server, no claim code, no password.
|
|
lg := log.New(io.Discard, "", 0)
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "s")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "d")
|
|
cfg.Stacks.ComposeCommand = "docker compose"
|
|
sett, _ := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
mgr, _ := stacks.NewManager(cfg, lg)
|
|
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
|
|
s.loadTemplates()
|
|
|
|
if s.claimGateActive() {
|
|
t.Fatal("no code hash → gate must NOT be active (legacy-open)")
|
|
}
|
|
if !s.claimLegacyOpen() {
|
|
t.Fatal("no password + no code → expected legacy-open")
|
|
}
|
|
|
|
// Password set → neither gated nor legacy-open.
|
|
pw, _ := bcrypt.GenerateFromPassword([]byte("existing-strong-pass"), 10)
|
|
sett.SetPasswordHash(string(pw))
|
|
if s.claimGateActive() || s.claimLegacyOpen() {
|
|
t.Fatal("a set password must disable both the gate and the legacy banner")
|
|
}
|
|
}
|
|
|
|
func claimFirstLine(s string) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|