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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user