3d7a2761fc
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
163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
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/<token> 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",
|
|
// 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: "hub_session", 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: "hub_session", 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: "hub_session", 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")
|
|
}
|
|
}
|