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:
@@ -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