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
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user