From b70dfdb80d897abf77b74a78eb59b984dfba64d4 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 14:08:43 +0300 Subject: Block bootstrap.html access after first user created (o9) serveBootstrap now calls CountUsers() and redirects to /login.html when the user count is non-zero, preventing the bootstrap form from being reachable on an already-configured instance. Co-Authored-By: Claude Opus 4.7 --- player-server/internal/api/handlers.go | 16 +++++++++++++++ player-server/internal/api/handlers_test.go | 30 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) (limited to 'player-server/internal/api') diff --git a/player-server/internal/api/handlers.go b/player-server/internal/api/handlers.go index 9cffa50..7f05435 100644 --- a/player-server/internal/api/handlers.go +++ b/player-server/internal/api/handlers.go @@ -169,7 +169,23 @@ func (s *Server) serveLogin(w http.ResponseWriter, r *http.Request) { s.serveFile(w, r, "login.html") } +// serveBootstrap serves bootstrap.html only when no users exist yet. +// Once the first admin account has been created the bootstrap page must no +// longer be reachable — redirecting to /login.html prevents an attacker +// from reaching the form on an already-configured instance. func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) { + if s.authSvc != nil { + count, err := s.authSvc.CountUsers(r.Context()) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if count > 0 { + // Bootstrap is complete; send browsers to the login page. + http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect) + return + } + } s.serveFile(w, r, "bootstrap.html") } diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go index 5fbd4c9..e031f40 100644 --- a/player-server/internal/api/handlers_test.go +++ b/player-server/internal/api/handlers_test.go @@ -433,14 +433,42 @@ func TestServer_StaticPages(t *testing.T) { } }) - t.Run("bootstrap public", func(t *testing.T) { + // After bootstrap is complete (users exist) the bootstrap page must + // redirect to /login.html so the form is no longer reachable. + t.Run("bootstrap redirects when users exist", func(t *testing.T) { srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/bootstrap.html", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) + if rr.Code != http.StatusTemporaryRedirect { + t.Fatalf("expected %d, got %d", http.StatusTemporaryRedirect, rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/login.html" { + t.Fatalf("expected redirect to /login.html, got %q", loc) + } + }) + + // Before bootstrap the page must be publicly accessible (no users yet). + t.Run("bootstrap accessible when no users", func(t *testing.T) { + noUserStore := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, + }, + } + noUserAuthSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 0, nil }, + GetUserByIDFunc: func(context.Context, int64) (*model.User, error) { return &model.User{ID: 1, IsAdmin: true}, nil }, + } + srv := newTestServer(t, noUserStore, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, noUserAuthSvc, nil) + req := httptest.NewRequest(http.MethodGet, "/bootstrap.html", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) } + if !strings.Contains(rr.Body.String(), "bootstrap") { + t.Fatal("expected bootstrap page body") + } }) t.Run("css public", func(t *testing.T) { -- cgit v1.2.3