package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // R-135 (hub v0.135.0): a state-changing request passes the gate only with (a) a live session cookie AND its // token, or (b) NO cookie, Basic credentials AND the OperatorCLIHeader. Everything else is 403 — on every // route, because the gate sits in ServeHTTP BEFORE the route switch. // // RED-PROOF (recorded in felhom.eu/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt): restore // the pre-v0.135.0 `return true` for a request with no cookie → TestR135_BasicAuthWithoutHeaderIsRefused // fails on every route (the handlers answer 303/404/400/200 instead of 403). // r135PostRoutes is EVERY state-changing route of the hub web server (server.go ServeHTTP), one // representative path each. /login and /bind/ are the two documented exemptions (no operator session // to ride; the bind URL token is the capability) and are NOT in this list. var r135PostRoutes = []string{ "/configuration", "/apps/demo/reset-telemetry", "/apps/demo/dismiss-issues", "/offsite/endpoints", "/offsite/endpoints/1/delete", "/appliances/1/bind", "/appliances/1/discard", "/hosts/h1/delete", "/hosts/h1/reveal-recovery-credential", "/hosts/h1/request-logs", "/customers/c1/block", "/customers/c1/selfbind-link", "/customers/c1/unblock", "/customers/c1/geo/disable", "/customers/c1/floor", "/customers/c1/create-config", "/customers/c1/request-log-tail", "/configs/new", "/configuration/global-floor", "/configuration/artifacts", "/configuration/password", "/configs/c1/delete", "/configs/c1/edit", "/configs/c1/offsite-reissue", "/configs/c1/claim-resend", "/configs/c1/pbsdr-reissue", "/configs/c1/offsite-freeze", "/configs/c1/regen-password", "/configs/c1/reset", "/offsite/remove-unpinned/c1", "/offsite/abandon-cancel/c1", "/offsite/window-grant/c1", "/offsite/windows-enabled", "/offsite/key-audit", "/os/ring/h1", "/os/enabled/h1", "/os/approve-now", "/os/approve-docker", "/os/approve-pve", "/os/approve-kernel", // Not a route: the gate must refuse BEFORE routing, so even an unknown path is 403, never 404. "/no-such-route", } // r135Handler is the production wiring: RequireAuth around ServeHTTP (cmd/hub/main.go). func r135Handler(t *testing.T) (*Server, http.Handler) { t.Helper() s, _ := serverWithPassword(t, "op-pass") return s, s.RequireAuth(http.HandlerFunc(s.ServeHTTP)) } func r135Post(h http.Handler, path string, mut func(*http.Request)) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"x": {"1"}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") mut(r) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } // The measured shape of R-135: Basic credentials and no cookie — what a browser with cached Basic auth sends // when another site makes it POST a form. Refused on every route. func TestR135_BasicAuthWithoutHeaderIsRefused(t *testing.T) { _, h := r135Handler(t) for _, p := range r135PostRoutes { w := r135Post(h, p, func(r *http.Request) { r.SetBasicAuth("", "op-pass") r.Header.Set("Origin", "https://evil.example") }) if w.Code != http.StatusForbidden { t.Errorf("POST %s with Basic auth and no %s header: %d, want 403", p, OperatorCLIHeader, w.Code) } } } // A browser session without its token: refused on every route (this half was already right; pinned here). func TestR135_SessionWithoutTokenIsRefused(t *testing.T) { s, h := r135Handler(t) s.sessionsMu.Lock() 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: 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: SessionCookieName, Value: "sess1"}) r.Header.Set("X-CSRF-Token", "wrong") }) if w.Code != http.StatusForbidden { t.Errorf("POST %s with a session and a wrong token: %d, want 403", p, w.Code) } } } // The two ways that pass: they reach the handler (any answer but the gate's 403 body). func TestR135_TheTwoAllowedShapesPassTheGate(t *testing.T) { s, h := r135Handler(t) s.sessionsMu.Lock() s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} s.sessionsMu.Unlock() gate := "CSRF token missing or invalid" for _, p := range r135PostRoutes { w := r135Post(h, p, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) r.Header.Set("X-CSRF-Token", "tok1") }) if strings.Contains(w.Body.String(), gate) { t.Errorf("POST %s with a session and its token was refused by the gate", p) } w = r135Post(h, p, func(r *http.Request) { r.SetBasicAuth("", "op-pass") r.Header.Set(OperatorCLIHeader, "cli") }) if strings.Contains(w.Body.String(), gate) { t.Errorf("POST %s with Basic auth and the %s header was refused by the gate", p, OperatorCLIHeader) } } } // The header alone proves nothing: without Basic credentials RequireAuth stops it before the gate. func TestR135_HeaderWithoutCredentialsIsNotEnough(t *testing.T) { _, h := r135Handler(t) w := r135Post(h, "/configuration/global-floor", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") }) if w.Code != http.StatusFound && w.Code != http.StatusUnauthorized { t.Fatalf("header with no credentials: %d, want a redirect to /login or 401", w.Code) } } // Reads are not gated: a GET with Basic auth and no header still works (the page renders or redirects). func TestR135_GetIsNotGated(t *testing.T) { _, h := r135Handler(t) r := httptest.NewRequest(http.MethodGet, "/hosts", nil) r.SetBasicAuth("", "op-pass") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code == http.StatusForbidden { t.Fatalf("GET /hosts with Basic auth was refused by the CSRF gate") } }