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
+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 ""
}