592818492c
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
326 lines
14 KiB
Go
326 lines
14 KiB
Go
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 A–F.
|
||
//
|
||
// 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
|
||
}
|