From e221476ff5e0952d1c806f8bfe881a1a84f6aeec Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:20:54 +0200 Subject: [PATCH] R-136: operator session cookie renamed to __Host-hub_session (always Secure, Path=/, no Domain) A sibling subdomain can no longer toss a session cookie the hub reads first. Operators are logged out once; plain-HTTP browser login no longer holds a session; Basic auth for scripts is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/web/apps.go | 2 +- .../web/hosts_recovery_reveal_test.go | 2 +- hub/internal/web/r135_csrf_test.go | 6 +- hub/internal/web/r136_host_cookie_test.go | 75 +++++++++++++++++++ hub/internal/web/server.go | 22 ++++-- 5 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 hub/internal/web/r136_host_cookie_test.go diff --git a/hub/internal/web/apps.go b/hub/internal/web/apps.go index 2270ef3c..06b9fdfc 100644 --- a/hub/internal/web/apps.go +++ b/hub/internal/web/apps.go @@ -348,7 +348,7 @@ func accuracyClass(p95MB float64, limitStr string) string { // getCSRFToken retrieves the CSRF token from the session cookie. func (s *Server) getCSRFToken(r *http.Request) string { - cookie, err := r.Cookie("hub_session") + cookie, err := r.Cookie(SessionCookieName) if err != nil { return "" } diff --git a/hub/internal/web/hosts_recovery_reveal_test.go b/hub/internal/web/hosts_recovery_reveal_test.go index cdadb083..36a3d8c4 100644 --- a/hub/internal/web/hosts_recovery_reveal_test.go +++ b/hub/internal/web/hosts_recovery_reveal_test.go @@ -59,7 +59,7 @@ func newRevealSession(t *testing.T, s *Server) (*http.Cookie, string) { csrfToken: "csrf-token-reveal", } s.sessionsMu.Unlock() - return &http.Cookie{Name: "hub_session", Value: "sess-token-reveal"}, "csrf-token-reveal" + return &http.Cookie{Name: SessionCookieName, Value: "sess-token-reveal"}, "csrf-token-reveal" } // seedRevealHost creates a host (optionally bound to a customer) with a vaulted credential. diff --git a/hub/internal/web/r135_csrf_test.go b/hub/internal/web/r135_csrf_test.go index 683536a4..e5a7cd93 100644 --- a/hub/internal/web/r135_csrf_test.go +++ b/hub/internal/web/r135_csrf_test.go @@ -101,12 +101,12 @@ func TestR135_SessionWithoutTokenIsRefused(t *testing.T) { s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} s.sessionsMu.Unlock() for _, p := range r135PostRoutes { - w := r135Post(h, p, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) }) + w := r135Post(h, p, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) }) if w.Code != http.StatusForbidden { t.Errorf("POST %s with a session and no token: %d, want 403", p, w.Code) } w = r135Post(h, p, func(r *http.Request) { - r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) r.Header.Set("X-CSRF-Token", "wrong") }) if w.Code != http.StatusForbidden { @@ -124,7 +124,7 @@ func TestR135_TheTwoAllowedShapesPassTheGate(t *testing.T) { gate := "CSRF token missing or invalid" for _, p := range r135PostRoutes { w := r135Post(h, p, func(r *http.Request) { - r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) r.Header.Set("X-CSRF-Token", "tok1") }) if strings.Contains(w.Body.String(), gate) { diff --git a/hub/internal/web/r136_host_cookie_test.go b/hub/internal/web/r136_host_cookie_test.go new file mode 100644 index 00000000..322d6e32 --- /dev/null +++ b/hub/internal/web/r136_host_cookie_test.go @@ -0,0 +1,75 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// R-136: the operator session cookie is `__Host-hub_session` — Secure, Path=/, no Domain — even when the +// login itself arrived over plain HTTP (the browser would reject a non-Secure __Host- cookie; a sibling +// subdomain can never set one). The old `hub_session` name no longer opens a session (the one-time +// logout), and plain-HTTP Basic auth for scripts keeps working. +// RED-PROOF: set SessionCookieName back to "hub_session" → the name/Secure assertions fail. +func TestR136_LoginSetsHostPrefixedCookie(t *testing.T) { + s, _ := serverWithPassword(t, "op-pass") + h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP)) + + r := httptest.NewRequest(http.MethodPost, "http://hub.local/login", strings.NewReader(url.Values{"password": {"op-pass"}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("login = %d", w.Code) + } + var sess *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == SessionCookieName { + sess = c + } + } + if SessionCookieName != "__Host-hub_session" || sess == nil { + t.Fatalf("login set no %q cookie (const=%q, got %v)", "__Host-hub_session", SessionCookieName, w.Result().Cookies()) + } + raw := w.Header().Get("Set-Cookie") + if !sess.Secure || sess.Path != "/" || sess.Domain != "" || strings.Contains(strings.ToLower(raw), "domain=") || !sess.HttpOnly { + t.Fatalf("__Host- preconditions broken (plain-HTTP login): %q", raw) + } + + // The new cookie opens the session. + r = httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: sess.Value}) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code == http.StatusFound && w.Header().Get("Location") == "/login" { + t.Fatal("the __Host- session cookie did not authenticate") + } + + // The same token under the OLD name (a tossed or stale cookie) does not. + r = httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "hub_session", Value: sess.Value}) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" { + t.Fatalf("old hub_session cookie = %d %q, want a redirect to /login", w.Code, w.Header().Get("Location")) + } + + // Plain-HTTP Basic auth (scripts, no cookie) still reads pages and, with the CLI header, writes. + r = httptest.NewRequest(http.MethodGet, "http://hub.local/", nil) + r.SetBasicAuth("", "op-pass") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code == http.StatusFound || w.Code == http.StatusUnauthorized { + t.Fatalf("plain-HTTP Basic auth GET = %d, want through", w.Code) + } + if !s.validateCSRF(func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "http://hub.local/configuration", nil) + r.SetBasicAuth("", "op-pass") + r.Header.Set(OperatorCLIHeader, "cli") + return r + }()) { + t.Fatal("plain-HTTP Basic auth + CLI header no longer passes the write gate") + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 7fd13c21..16500347 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -830,7 +830,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { } // Check session cookie (random token stored server-side) - if cookie, err := r.Cookie("hub_session"); err == nil { + if cookie, err := r.Cookie(SessionCookieName); err == nil { s.sessionsMu.RLock() sess, ok := s.sessions[cookie.Value] s.sessionsMu.RUnlock() @@ -857,6 +857,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { }) } +// SessionCookieName is the operator browser session cookie (R-136). The `__Host-` prefix makes the +// browser refuse it unless it is Secure, Path=/ and carries no Domain — so a sibling subdomain can no +// longer plant ("toss") a session cookie the hub would read first (r.Cookie returns the FIRST match). +// The rename from `hub_session` logs every operator out once. Pinned by r136_host_cookie_test.go. +const SessionCookieName = "__Host-hub_session" + func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { password := r.FormValue("password") @@ -879,14 +885,16 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } s.sessionsMu.Unlock() - isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + // R-136: a __Host- cookie is always Secure, Path=/ and has no Domain (the browser rejects + // it otherwise). Plain-HTTP BROWSER login therefore no longer holds a session — accepted; + // scripts use Basic auth, which needs no cookie. http.SetCookie(w, &http.Cookie{ - Name: "hub_session", + Name: SessionCookieName, Value: sessionToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, - Secure: isSecure, + Secure: true, MaxAge: 86400 * 7, }) http.Redirect(w, r, "/", http.StatusSeeOther) @@ -913,14 +921,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { const OperatorCLIHeader = "X-Felhom-Operator" // validateCSRF checks a state-changing request (R-135). Two ways pass, nothing else: -// - a browser session: the hub_session cookie names a live session AND the form/header token matches it; +// - a browser session: the session cookie (SessionCookieName) names a live session AND the form/header token matches it; // - a programmatic operator call: NO session cookie, HTTP Basic credentials present (RequireAuth has // already checked them) AND the OperatorCLIHeader is set. // // Before v0.135.0 a request with no session cookie passed unconditionally (measured live: a Basic-auth // POST with no cookie reached the handler). func (s *Server) validateCSRF(r *http.Request) bool { - cookie, err := r.Cookie("hub_session") + cookie, err := r.Cookie(SessionCookieName) if err != nil { _, _, basic := r.BasicAuth() return basic && strings.TrimSpace(r.Header.Get(OperatorCLIHeader)) != "" @@ -942,7 +950,7 @@ func (s *Server) validateCSRF(r *http.Request) bool { // csrfToken returns the CSRF token for the current session. func (s *Server) csrfToken(r *http.Request) string { - cookie, err := r.Cookie("hub_session") + cookie, err := r.Cookie(SessionCookieName) if err != nil { return "" }