Files
felhom.eu/hub/internal/web/r135_csrf_test.go
T

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")
}
}