hub v0.135.0: CSRF on the Basic-auth path (R-135), console passwords sealed at rest (R-133), boxes left behind listed and alarmed (R-604, R-530), no-e-mail banner (R-508)
gates / gates (push) Successful in 29s

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 11:12:56 +02:00
parent 9bb45eaaa2
commit 3d7a2761fc
24 changed files with 1148 additions and 17 deletions
+29 -4
View File
@@ -100,6 +100,10 @@ type Server struct {
// the CONTROLLER plane (customer/app config) via the long-poll wait channel.
poke *poke.Notifier
// emit sends an operator event through the notification dispatcher (R-604, hub v0.135.0: the "a floor raise
// skipped boxes" mail). nil = log only. Wired in cmd/hub/main.go (SetEventEmitter).
emit func(customerID, eventType, severity, message, detailsJSON, source string)
sessions map[string]*hubSession
sessionsMu sync.RWMutex
@@ -372,6 +376,11 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
return out
}
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
s.emit = f
}
// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@@ -865,13 +874,29 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// validateCSRF checks the CSRF token for a session-based request.
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
// OperatorCLIHeader is the header a programmatic (Basic-auth, cookie-less) operator request must carry
// to change state (R-135, hub v0.135.0). Any non-empty value; the docs and scripts send "cli".
//
// WHY A HEADER. Browsers cache HTTP Basic credentials per origin and resend them on cross-site
// requests, and SameSite does not govern the Authorization header — so "Basic auth and no cookie"
// does NOT prove the request is programmatic. A page on another site can make the browser POST a
// form with the operator's cached Basic credentials; it cannot add a custom header (that needs a
// CORS preflight, which the hub never answers). So the header is the proof the old check assumed.
// Decided by CC — operator may reverse (`05` §8.1). Pinned by r135_csrf_test.go.
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 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")
if err != nil {
// No session cookie — likely Basic Auth or programmatic access; skip CSRF
return true
_, _, basic := r.BasicAuth()
return basic && strings.TrimSpace(r.Header.Get(OperatorCLIHeader)) != ""
}
s.sessionsMu.RLock()