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
This commit is contained in:
2026-07-17 23:56:53 +02:00
parent c6d7a69e6d
commit 592818492c
24 changed files with 1304 additions and 138 deletions
+26 -3
View File
@@ -71,6 +71,8 @@ type Server struct {
pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
@@ -129,6 +131,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
templates: tmpl,
staleThreshold: staleThreshold,
sessions: make(map[string]*hubSession),
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
}
}
@@ -253,7 +256,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// CSRF protection for all state-changing requests (web routes only).
// API routes (/api/v1/) are Bearer-token authenticated and exempt.
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
if path != "/login" && s.effectivePasswordHash() != "" {
// /bind/ is the public customer self-bind surface (THE TRAP §9.2): no operator session to
// ride, so CSRF is exempt here exactly as it is for /login. The URL capability token is the
// authorization boundary; a cross-site POST without both secrets only burns attempts.
if path != "/login" && !isPublicBindPath(path) && s.effectivePasswordHash() != "" {
if !s.validateCSRF(r) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
@@ -377,6 +383,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleHostDetail(w, r, hostID)
case path == "/login":
s.handleLogin(w, r)
case isPublicBindPath(path):
// PUBLIC customer self-bind (R-27 slice 1) — GET renders the form/state, POST validates the
// two factors. Auth + CSRF exempt above via the SAME isPublicBindPath predicate.
s.handleBind(w, r)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/block")
@@ -385,6 +395,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/selfbind-link"):
// R-27 slice 1: mint + email a customer self-bind capability link. POST only.
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/selfbind-link")
if r.Method == http.MethodPost {
s.handleSelfBindLinkSend(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/unblock"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/unblock")
@@ -561,8 +580,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
// Always allow the login page through (GET and POST)
if r.URL.Path == "/login" {
// Always allow the login page through (GET and POST), and the PUBLIC customer self-bind
// surface (THE TRAP §9.2): /bind/ is exempt from operator auth exactly as /login is — the
// emailed URL capability token IS the auth model there. isPublicBindPath is the SINGLE
// definition of the prefix (matched tightly: trailing slash, path already .. -cleaned by the
// ServeMux) so this exemption cannot reach any operator-gated route.
if r.URL.Path == "/login" || isPublicBindPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}