Files
felhom.eu/hub/internal/web/selfbind_test.go
T
admin 592818492c hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)
Let a customer bind their own freshly-installed appliance without the
operator: operator "Send self-bind link" mints a 7-day tokenized
capability link, emailed (Hungarian, sibling sender) to the customer, who
opens a public /bind/<token> page and proves two factors — the console
pairing code shown on the box screen + their retrieval passphrase — and
the hub stages the bind via the same BindAppliance (provenance
customer_selfbind). The box's ~30s appliance poll delivers.

Viktor's three rulings verbatim: console pairing code (no appliance list
ever rendered), operator-sent tokenized link, 5-attempt lockout ->
"call support". Wrong code == wrong passphrase (one generic failure, no
oracle, both factors compared unconditionally); expiry falls back to
operator-bind unchanged.

THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login
gate sites via a single isPublicBindPath predicate (tight trailing-slash
match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests
(Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout,
oracle, widened-prefix, single-active). GC verdict: no appliance GC ->
the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred.

Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
2026-07-17 23:56:53 +02:00

326 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// selfbind_test.go — customer self-bind (v0.66.0, R-27 slice 1), Scenarios AF.
//
// Each red-proof below names the ONE line to break to turn a scenario red — the guard that the test
// actually pins. If a red-proof does NOT turn its scenario red, the test is hollow.
const (
testPass = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words)
testCode = "ABC234" // an appliance console pairing code (raw stored form)
testCodeFmt = "abc-234" // as a human might type it (lowercased, separated)
)
// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the
// given console pairing code, and returns the appliance id. Distinct customers must use distinct
// codes (a code shared by two registered appliances is ambiguous → binds nothing).
func selfBindSetup(t *testing.T, st *store.Store, customerID, code string) int64 {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: customerID, CustomerName: customerID, APIKey: "k",
RetrievalPassword: testPass, Email: customerID + "@example.test",
}); err != nil {
t.Fatal(err)
}
sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
if _, _, err := st.RegisterAppliance("uuid-"+customerID, "bc:24:11:98:10:0e", sshKey, `{"product":"N100"}`, "aphash-"+customerID, code); err != nil {
t.Fatal(err)
}
list, _ := st.ListUnclaimedAppliances()
for _, a := range list {
if a.UUID == "uuid-"+customerID {
return a.ID
}
}
t.Fatal("seeded appliance not found")
return 0
}
// mintLink mints a self-bind token for the customer and returns the plaintext token (URL segment).
// Each call uses a fresh nonce so distinct calls yield distinct tokens (single-active still applies —
// the store deletes the prior row for the customer on each mint).
var mintNonce int
func mintLink(t *testing.T, st *store.Store, customerID string, ttl time.Duration) string {
t.Helper()
mintNonce++
token := "tok-" + customerID + "-" + string(rune('a'+mintNonce%26)) + strconv.Itoa(mintNonce)
if err := st.MintSelfBindToken(customerID, selfBindHash(token), ttl); err != nil {
t.Fatal(err)
}
return token
}
func bindGET(t *testing.T, s *Server, token string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
s.handleBind(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
return rr
}
func bindPOST(t *testing.T, s *Server, token, code, pass string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"pairing_code": {code}, "passphrase": {pass}}
req := httptest.NewRequest("POST", "/bind/"+token, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleBind(rr, req)
return rr
}
// --- Scenario A: happy path (GET renders form; POST with both correct factors stages the bind) ---
func TestSelfBind_A_HappyPath(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "Párosító kód") || !strings.Contains(body, "action=\"/bind/"+token+"\"") {
t.Fatalf("GET did not render the entry form")
}
// A human types the code lowercased + separated and the passphrase with odd spacing — normalization
// must accept both.
rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
t.Fatalf("POST success page not rendered: code=%d body=%q", rr.Code, rr.Body.String())
}
// The appliance is bound to the customer (same effect as an operator bind).
if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound || a.CustomerID != "acme" {
t.Fatalf("appliance not bound: %+v", a)
}
// Provenance event is customer self-bind, not operator/hub.
ev, _ := st.GetLatestEventByType("acme", "appliance_bound")
if ev == nil || ev.Source != "customer_selfbind" {
t.Fatalf("expected customer_selfbind provenance event, got %+v", ev)
}
// One-shot: the token is now consumed; a second open shows the consumed state.
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "már fel lett használva") {
t.Fatalf("token not consumed after success")
}
// Red-proof: drop the ConsumeSelfBindToken call (or make BindAppliance the only effect) → the
// token stays live and this consumed-state assertion goes red.
}
// --- Scenario B: NO ORACLE — wrong code and wrong passphrase yield the SAME generic failure ---
func TestSelfBind_B_NoOracle(t *testing.T) {
s, st := newTestServer(t)
// Two customers with DISTINCT pairing codes so the single-active mint does not cross-invalidate,
// and each has one fresh (attempt-count 1) token — the only difference between the two failure
// pages is then the token in the form action, which we normalize out.
selfBindSetup(t, st, "acme", testCode)
selfBindSetup(t, st, "acme2", "XYZ789")
t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase)
t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code)
wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN")
wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN")
if wrongCode != wrongPass {
t.Fatalf("failure pages differ between wrong-code and wrong-passphrase — that is an oracle")
}
// The generic failure must not leak any appliance data.
if !strings.Contains(wrongCode, "nem megfelelőek") {
t.Fatalf("failure page missing the generic banner: %q", wrongCode)
}
if strings.Contains(wrongCode, "uuid-") || strings.Contains(wrongCode, "N100") {
t.Fatalf("failure page leaked appliance data")
}
// Red-proof: give wrong-code and wrong-passphrase distinct messages/states (an oracle) → the
// wrongCode == wrongPass assertion goes red.
}
// --- Scenario C1: LOCKOUT after 5 failed attempts; a subsequent CORRECT attempt cannot bind ---
func TestSelfBind_C1_Lockout(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
for i := 1; i <= store.SelfBindMaxAttempts; i++ {
rr := bindPOST(t, s, token, "ZZZ999", "definitely wrong words indeed")
if i < store.SelfBindMaxAttempts && !strings.Contains(rr.Body.String(), "nem megfelelőek") {
t.Fatalf("attempt %d should re-render the form with a failure, got %q", i, rr.Body.String())
}
}
// The 5th failure locked it: even the CORRECT secrets now bind nothing.
rr := bindPOST(t, s, token, testCodeFmt, testPass)
if !strings.Contains(rr.Body.String(), "zárolva") {
t.Fatalf("token not locked after %d failures: %q", store.SelfBindMaxAttempts, rr.Body.String())
}
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("a locked link still bound the appliance: %+v", a)
}
// Red-proof: remove the `locked = attempts >= SelfBindMaxAttempts` lock (never lock) → the correct
// post-lockout POST binds and both the "zárolva" and still-registered assertions go red.
}
// --- Scenario C4: SINGLE-ACTIVE per customer — re-minting kills the prior link ---
func TestSelfBind_C4_SingleActive(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
first := mintLink(t, st, "acme", selfBindTTL)
second := mintLink(t, st, "acme", selfBindTTL) // re-mint for the SAME customer
if body := bindGET(t, s, first).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("the first link still resolves after a re-mint (not single-active): %q", body)
}
if body := bindGET(t, s, second).Body.String(); !strings.Contains(body, "Párosító kód") {
t.Fatalf("the freshly-minted link does not render the form: %q", body)
}
// Red-proof: drop the `DELETE FROM selfbind_tokens WHERE customer_id` in MintSelfBindToken → the
// first link still resolves and the érvénytelen assertion goes red.
}
// --- Scenario D: the operator auth gate is intact — self-bind's exemption did NOT open other routes ---
func TestSelfBind_D_AuthGateIntact(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
for _, path := range []string{"/", "/hosts", "/customers/acme", "/configuration", "/offsite"} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/login" {
t.Fatalf("gated route %s not redirected to /login: code=%d loc=%q", path, rr.Code, rr.Header().Get("Location"))
}
}
// Red-proof: this is the companion to Scenario E — widening isPublicBindPath turns THIS red too.
}
// --- Scenario E: THE TRAP — /bind/ is exempt from operator auth, matched TIGHTLY (no leak/traversal) ---
func TestSelfBind_E_TheTrap(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
// The public bind link renders WITHOUT a login (the exemption works).
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Párosító kód") {
t.Fatalf("public /bind/ link was gated or not rendered: code=%d", rr.Code)
}
// A sibling prefix must NOT be exempt: /bindsecret is still gated (tight trailing-slash match).
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bindsecret", nil))
if rr.Code != http.StatusFound {
t.Fatalf("/bindsecret leaked through the exemption (prefix not tight): code=%d", rr.Code)
}
// Traversal through the exempt prefix must not reach a gated handler: it stays inside handleBind,
// which rejects a token containing '/', never touching /hosts.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/../hosts", nil))
if strings.Contains(rr.Body.String(), "Unclaimed appliances") || strings.Contains(rr.Body.String(), "No hosts enrolled") {
t.Fatalf("path traversal through /bind/ reached the hosts page")
}
// Red-proof: widen isPublicBindPath to strings.HasPrefix(path, "/bind") (drop the slash) → the
// /bindsecret gated assertion goes red; broaden it further and Scenario D goes red too.
}
// --- Scenario F: EXPIRY falls back — an expired link binds nothing, even with correct factors ---
func TestSelfBind_F_ExpiryFallsBack(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", -1*time.Hour) // already expired
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("expired link did not render the expired state")
}
// Even the CORRECT secrets on an expired link bind nothing (operator-bind fallback is unchanged).
bindPOST(t, s, token, testCodeFmt, testPass)
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("an expired link still bound the appliance: %+v", a)
}
// Red-proof: drop the `expires_at > datetime('now')` guard in ConsumeSelfBindToken AND the
// tok.Expired() gate → the expired POST binds and the still-registered assertion goes red.
}
// --- Scenario (F1/F2): the operator MINT honesty paths ---
// F1: a customer with no registered email → nothing minted, LOUD flash.
func TestSelfBind_MintNoEmail(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "noemail", APIKey: "k", RetrievalPassword: testPass}); err != nil {
t.Fatal(err)
}
s.SetSelfBindMailer(&stubMailer{})
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/customers/noemail/selfbind-link", nil)
s.handleSelfBindLinkSend(rr, req, "noemail")
if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "selfbind-no-email") {
t.Fatalf("no-email mint should redirect with selfbind-no-email: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash("x")); tok != nil {
t.Fatal("a token was minted despite no email")
}
}
// F2: the email send fails → the just-minted token is deleted (not left silently live).
func TestSelfBind_MintSendFailsCleansUp(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
stub := &stubMailer{fail: true}
s.SetSelfBindMailer(stub)
rr := httptest.NewRecorder()
s.handleSelfBindLinkSend(rr, httptest.NewRequest("POST", "/customers/acme/selfbind-link", nil), "acme")
if !strings.Contains(rr.Header().Get("Location"), "selfbind-send-failed") {
t.Fatalf("send failure should redirect with selfbind-send-failed: %q", rr.Header().Get("Location"))
}
// The token that was minted for the (failed) send is gone — not left silently live (F2 cleanup).
// Recover the token from the link the mailer was handed, and confirm it no longer resolves.
tokenFromLink := stub.link[strings.LastIndexByte(stub.link, '/')+1:]
if tokenFromLink == "" {
t.Fatal("mailer was never handed a link")
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash(tokenFromLink)); tok != nil {
t.Fatal("a failed send left a live token behind")
}
}
// stubMailer records the last send and can be told to fail.
type stubMailer struct {
fail bool
link string
}
func (m *stubMailer) SendSelfBindEmail(customerID, email, link string) error {
m.link = link
if m.fail {
return errStubSend
}
return nil
}
var errStubSend = &stubErr{}
type stubErr struct{}
func (*stubErr) Error() string { return "stub send failure" }
// newAuthServer is newTestServer with an operator password configured, so RequireAuth is live.
func newAuthServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
s, st := newTestServer(t)
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
s.configPasswordHash = string(h)
return s, st
}