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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:20:54 +02:00
parent 5f060e3d1e
commit e221476ff5
5 changed files with 95 additions and 12 deletions
+1 -1
View File
@@ -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 ""
}
@@ -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.
+3 -3
View File
@@ -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) {
+75
View File
@@ -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")
}
}
+15 -7
View File
@@ -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 ""
}