From 46030a5b144fd7bcf97681bb2176043cc78047bb Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Fri, 2 Oct 2026 18:56:52 +0200 Subject: [PATCH] fix installer --- .dockerignore | 1 + .gitignore | 1 + client/client.go | 2 + compose.yaml => docker-compose.yaml | 8 ++- internal/server/installer.go | 17 +++++- internal/server/installer_test.go | 85 +++++++++++++++++++++++++++++ internal/server/server.go | 4 +- 7 files changed, 113 insertions(+), 5 deletions(-) rename compose.yaml => docker-compose.yaml (88%) create mode 100644 internal/server/installer_test.go diff --git a/.dockerignore b/.dockerignore index 324aa90..93e14b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ .env bin coverage.out +google_secrets.json diff --git a/.gitignore b/.gitignore index f6246e3..cf3bd3c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .vscode/ bin/ coverage.out +google_secrets.json diff --git a/client/client.go b/client/client.go index 4f49be4..c0d57b6 100644 --- a/client/client.go +++ b/client/client.go @@ -47,6 +47,8 @@ type authorizationResponse struct { Principal } +const XTenantId = "X-Tenant-Id" + type contextKey struct{} func New(cfg Config) (*Client, error) { diff --git a/compose.yaml b/docker-compose.yaml similarity index 88% rename from compose.yaml rename to docker-compose.yaml index 78bfbc3..614f47b 100644 --- a/compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,8 @@ services: interval: 5s timeout: 3s retries: 10 + networks: + - go_loose_network go-loose: build: . depends_on: @@ -29,13 +31,17 @@ services: GO_LOOSE_LOCAL_LOGIN: "true" GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" ports: - - "${GO_LOOSE_PORT:-8080}:8080" + - "${GO_LOOSE_PORT:-8081}:8080" healthcheck: test: ["CMD", "/go-loose", "-healthcheck"] interval: 10s timeout: 3s retries: 5 start_period: 10s + networks: + - go_loose_network volumes: postgres-data2: +networks: + go_loose_network: \ No newline at end of file diff --git a/internal/server/installer.go b/internal/server/installer.go index 0c1a1ea..b2e44b2 100644 --- a/internal/server/installer.go +++ b/internal/server/installer.go @@ -24,7 +24,7 @@ func (s *Server) installPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "installer unavailable", http.StatusInternalServerError) return } - csrfToken, err := s.installCSRFToken(w) + csrfToken, err := s.installCSRFToken(w, r) if err != nil { http.Error(w, "could not prepare installer", http.StatusInternalServerError) return @@ -118,16 +118,27 @@ func buildDemoUsers() ([]store.DemoUser, error) { return result, nil } -func (s *Server) installCSRFToken(w http.ResponseWriter) (string, error) { +func (s *Server) installCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { + if cookie, err := r.Cookie("go_loose_install_csrf"); err == nil { + payload, valid := s.sessions.VerifySigned(cookie.Value) + if valid && strings.HasPrefix(payload, "install|") { + s.setInstallCSRFCookie(w, cookie.Value) + return cookie.Value, nil + } + } state, err := s.sessions.NewState() if err != nil { return "", err } token := s.sessions.SignedState("install|" + state) + s.setInstallCSRFCookie(w, token) + return token, nil +} + +func (s *Server) setInstallCSRFCookie(w http.ResponseWriter, token string) { http.SetCookie(w, &http.Cookie{ Name: "go_loose_install_csrf", Value: token, Path: "/install", HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"), SameSite: http.SameSiteStrictMode, MaxAge: 900, }) - return token, nil } diff --git a/internal/server/installer_test.go b/internal/server/installer_test.go new file mode 100644 index 0000000..63f9758 --- /dev/null +++ b/internal/server/installer_test.go @@ -0,0 +1,85 @@ +package server + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danyel/go-loose/internal/config" + "github.com/danyel/go-loose/internal/session" +) + +func newInstallerTestServer() *Server { + secret := strings.Repeat("s", 32) + return &Server{ + cfg: config.Config{BaseURL: "http://localhost:8080", SessionSecret: secret}, + sessions: session.New(secret, false), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +func TestFaviconDoesNotRedirectToInstaller(t *testing.T) { + server := newInstallerTestServer() + request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusNotFound { + t.Fatalf("favicon status = %d, want 404; location %q", recorder.Code, recorder.Header().Get("Location")) + } + if recorder.Header().Get("Set-Cookie") != "" { + t.Fatal("favicon response set a cookie") + } +} + +func TestUninstalledRootRedirectsToInstaller(t *testing.T) { + server := newInstallerTestServer() + request := httptest.NewRequest(http.MethodGet, "/", nil) + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != "/install" { + t.Fatalf("status %d location %q", recorder.Code, recorder.Header().Get("Location")) + } +} + +func TestInstallerCSRFTokenSurvivesRepeatedPageLoad(t *testing.T) { + server := newInstallerTestServer() + first := httptest.NewRecorder() + server.Handler().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/install", nil)) + if first.Code != http.StatusOK { + t.Fatalf("first install page status = %d", first.Code) + } + cookie := first.Result().Cookies()[0] + token := csrfTokenFromInstaller(t, first.Body.String()) + if cookie.Value != token { + t.Fatalf("cookie %q does not match form token %q", cookie.Value, token) + } + + secondRequest := httptest.NewRequest(http.MethodGet, "/install", nil) + secondRequest.AddCookie(cookie) + second := httptest.NewRecorder() + server.Handler().ServeHTTP(second, secondRequest) + if got := csrfTokenFromInstaller(t, second.Body.String()); got != token { + t.Fatalf("second page rotated CSRF token to %q", got) + } + if second.Result().Cookies()[0].Value != token { + t.Fatal("second response rotated CSRF cookie") + } +} + +func csrfTokenFromInstaller(t *testing.T, page string) string { + t.Helper() + const marker = `name="csrf_token" value="` + start := strings.Index(page, marker) + if start < 0 { + t.Fatal("installer page has no csrf token") + } + start += len(marker) + end := strings.Index(page[start:], `"`) + if end < 0 { + t.Fatal("installer csrf token is unterminated") + } + return page[start : start+end] +} diff --git a/internal/server/server.go b/internal/server/server.go index 88c877b..95defdc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,7 +114,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /connect/logout", s.clientLogout) mux.HandleFunc("GET /client/login", s.clientLoginPage) - mux.Handle("GET /", s.requireSession(http.HandlerFunc(s.index))) + // {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a + // browser favicon request would redirect to /install and rotate its CSRF cookie. + mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index))) mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs))) mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi))) mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))