Files
pad/internal/server/handlers_auth_bootstrap_token_test.go
T
xarmian 05a9665f50 feat(auth): first-run logs-token bootstrap flow (TASK-1167) (#424)
One-time bootstrap token generated on first start with no users in self-host
mode. Token is logged in a banner the operator can grab from `docker logs`,
persists at <DataDir>/.bootstrap-token (mode 0600), and bypasses the
loopback-only gate via the X-Bootstrap-Token header — letting the user
claim the first admin from a remote browser at /setup#token=<x>.

Header-only contract + URL-fragment (browser-only, never transmitted) +
log-redaction middleware keeps the secret out of access logs, proxy logs,
and browser history. Cloud mode unchanged: token never loaded, never
honored. Validate → UserCount-check → CreateUser → consume sequence is
mutex-serialized to prevent concurrent valid-token requests from creating
multiple admins.

Part of PLAN-1166 (Pad on Unraid — Community Apps launch).
2026-05-06 08:40:11 -04:00

396 lines
13 KiB
Go

package server
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
// Tests for the first-run logs-token bootstrap flow (TASK-1167 / PLAN-1166).
// These tests pin the security-critical contracts that codex review
// surfaced before implementation:
//
// - F2: cloud mode never accepts the token bypass.
// - F5: concurrent valid-token requests cannot create multiple admins.
// - F6: token is accepted via X-Bootstrap-Token header only — never via
// ?token= query.
// - F9: cloud mode never advertises setup_method=logs_token.
//
// Plus regression-safe coverage of the existing loopback gate and the
// session-check setup_method response.
const testBootstrapToken = "test-bootstrap-token-fixed-value"
// configureBootstrapToken seeds a known token on the server so tests don't
// depend on the random generator. The on-disk file is created in a temp
// dir so the consume-removes-file assertion has a real file to delete.
func configureBootstrapToken(t *testing.T, srv *Server) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".bootstrap-token")
if err := os.WriteFile(path, []byte(testBootstrapToken+"\n"), 0600); err != nil {
t.Fatalf("seed token file: %v", err)
}
srv.SetBootstrapToken(testBootstrapToken, path)
return path
}
func bootstrapBody(email string) map[string]string {
return map[string]string{
"email": email,
"name": "Admin",
"password": "correct-horse-battery-staple",
}
}
// doRequestWithHeadersFromAddr lets us combine arbitrary headers with a
// non-loopback remote-addr. Existing helpers cover one or the other but
// not both.
func doRequestWithHeadersFromAddr(srv *Server, method, path string, body interface{}, headers map[string]string, remoteAddr string) *httptest.ResponseRecorder {
var bodyReader io.Reader
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
req := httptest.NewRequest(method, path, bodyReader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.RemoteAddr = remoteAddr
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// TestBootstrapToken_NonLoopback_HeaderAccepted is the happy path: a
// non-loopback peer presents the correct X-Bootstrap-Token header and
// the bootstrap succeeds, the file is deleted, and the in-memory token
// is cleared.
func TestBootstrapToken_NonLoopback_HeaderAccepted(t *testing.T) {
srv := testServer(t)
tokenPath := configureBootstrapToken(t, srv)
rr := doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody("admin@test.com"),
map[string]string{BootstrapTokenHeader: testBootstrapToken},
"192.0.2.1:1234")
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201 with valid header, got %d: %s", rr.Code, rr.Body.String())
}
// File deleted.
if _, err := os.Stat(tokenPath); !os.IsNotExist(err) {
t.Fatalf("token file still exists after bootstrap: stat err = %v", err)
}
// In-memory cleared.
if srv.hasBootstrapToken() {
t.Fatal("hasBootstrapToken() = true after consume, want false")
}
}
// TestBootstrapToken_NonLoopback_NoHeader pins the loopback-only default
// for self-host without a token configured (regression coverage —
// existing behavior must not change).
func TestBootstrapToken_NonLoopback_NoHeader(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
rr := doRequest(srv, "POST", "/api/v1/auth/bootstrap", bootstrapBody("admin@test.com"))
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403 without header, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestBootstrapToken_NonLoopback_WrongHeader covers wrong-token rejection.
func TestBootstrapToken_NonLoopback_WrongHeader(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
rr := doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody("admin@test.com"),
map[string]string{BootstrapTokenHeader: "wrong-token"},
"192.0.2.1:1234")
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403 with wrong token, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestBootstrapToken_NonLoopback_QueryRejected pins the F6 contract: token
// must NOT be accepted via ?token= query, only via X-Bootstrap-Token
// header. This prevents the secret from landing in any caller's access
// log, browser history, or proxy log.
func TestBootstrapToken_NonLoopback_QueryRejected(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
// No header — only query param. Must be rejected.
rr := doRequest(srv, "POST",
"/api/v1/auth/bootstrap?token="+testBootstrapToken,
bootstrapBody("admin@test.com"))
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403 with token in query (header-only contract), got %d: %s", rr.Code, rr.Body.String())
}
}
// TestBootstrapToken_Replay_AfterConsume verifies that once the first
// admin is created, the same token cannot be reused — even by the same
// client.
func TestBootstrapToken_Replay_AfterConsume(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
// Round 1 — succeeds.
rr := doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody("admin@test.com"),
map[string]string{BootstrapTokenHeader: testBootstrapToken},
"192.0.2.1:1234")
if rr.Code != http.StatusCreated {
t.Fatalf("first bootstrap: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
// Round 2 — same token replayed. After consume it's been cleared in
// memory + the file is deleted, so the validate path returns false
// and we fall back to the loopback gate (which fails: non-loopback
// peer). UserCount > 0 also bails the inner check, but we expect
// the outer gate to win first with 403.
rr = doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody("second@test.com"),
map[string]string{BootstrapTokenHeader: testBootstrapToken},
"192.0.2.1:1234")
if rr.Code == http.StatusCreated {
t.Fatalf("replay succeeded with consumed token: %s", rr.Body.String())
}
}
// TestBootstrapToken_CloudMode_NoBypass pins the F2 contract: a fresh
// cloud instance with UserCount=0 + a token configured + a non-loopback
// peer presenting the correct header → still 403. Cloud bootstrap
// remains loopback-only regardless of token state.
//
// In production, cmd/pad/main.go never calls EnsureBootstrapToken when
// the instance is in cloud mode (D10), so the token would never even be
// loaded. This test forces the token in anyway to pin the
// defense-in-depth check inside handleBootstrap itself.
func TestBootstrapToken_CloudMode_NoBypass(t *testing.T) {
srv := testServer(t)
srv.SetCloudMode("test-cloud-secret")
configureBootstrapToken(t, srv)
rr := doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody("admin@test.com"),
map[string]string{BootstrapTokenHeader: testBootstrapToken},
"192.0.2.1:1234")
if rr.Code != http.StatusForbidden {
t.Fatalf("cloud-mode token bypass leaked! expected 403, got %d: %s", rr.Code, rr.Body.String())
}
// Token must still be present (consume only fires on success).
if !srv.hasBootstrapToken() {
t.Fatal("token was consumed on a rejected cloud-mode bootstrap")
}
}
// TestBootstrapToken_ConcurrentRace pins the F5 contract: N simultaneous
// requests with the correct token + different emails must produce
// exactly one success. The mutex in handleBootstrap serializes the
// validate → check-UserCount → CreateUser → consume sequence so the
// loser sees an empty in-memory token after the winner consumes.
func TestBootstrapToken_ConcurrentRace(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
const N = 8
var (
successes int32
failures int32
)
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
email := "admin" + string(rune('0'+idx)) + "@test.com"
rr := doRequestWithHeadersFromAddr(srv, "POST", "/api/v1/auth/bootstrap",
bootstrapBody(email),
map[string]string{BootstrapTokenHeader: testBootstrapToken},
"192.0.2.1:1234")
if rr.Code == http.StatusCreated {
atomic.AddInt32(&successes, 1)
} else {
atomic.AddInt32(&failures, 1)
}
}(i)
}
wg.Wait()
if successes != 1 {
t.Fatalf("expected exactly 1 successful bootstrap, got %d (failures: %d)", successes, failures)
}
if failures != N-1 {
t.Fatalf("expected %d failures, got %d", N-1, failures)
}
// And exactly 1 admin in the DB.
count, err := srv.store.UserCount()
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 admin after concurrent race, got %d (this is the F5 multi-admin bug)", count)
}
}
// TestBootstrapToken_SessionCheck_LogsToken pins the setup_method value
// returned by /api/v1/auth/session when a token is configured and no
// users exist — the frontend's SetupRequiredNotice branches on this to
// render the "paste your bootstrap token from the container logs" UI.
func TestBootstrapToken_SessionCheck_LogsToken(t *testing.T) {
srv := testServer(t)
configureBootstrapToken(t, srv)
rr := doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Fatalf("session check: expected 200, got %d", rr.Code)
}
var session map[string]interface{}
parseJSON(t, rr, &session)
if session["setup_required"] != true {
t.Errorf("expected setup_required=true with no users, got %v", session["setup_required"])
}
if session["setup_method"] != "logs_token" {
t.Errorf("expected setup_method=logs_token when token is loaded + no users, got %v", session["setup_method"])
}
}
// TestBootstrapToken_SessionCheck_LocalCLI_NoToken pins the existing
// fallback: when no token is configured (e.g. read-only data dir per
// D7), the session payload returns the existing local_cli value.
func TestBootstrapToken_SessionCheck_LocalCLI_NoToken(t *testing.T) {
srv := testServer(t)
// Note: NOT calling configureBootstrapToken — leaves token empty.
rr := doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Fatalf("session check: expected 200, got %d", rr.Code)
}
var session map[string]interface{}
parseJSON(t, rr, &session)
if session["setup_method"] != "local_cli" {
t.Errorf("expected setup_method=local_cli with no token loaded, got %v", session["setup_method"])
}
}
// TestBootstrapToken_SessionCheck_CloudMode_NoLogsToken pins the F9
// contract: cloud-mode session must NEVER advertise logs_token, even if
// a token is somehow loaded (shouldn't happen via cmd/pad/main.go but
// the handler must defend itself anyway).
func TestBootstrapToken_SessionCheck_CloudMode_NoLogsToken(t *testing.T) {
srv := testServer(t)
srv.SetCloudMode("test-cloud-secret")
configureBootstrapToken(t, srv)
rr := doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Fatalf("session check: expected 200, got %d", rr.Code)
}
var session map[string]interface{}
parseJSON(t, rr, &session)
if session["setup_method"] == "logs_token" {
t.Fatalf("cloud-mode advertised setup_method=logs_token (F9 regression)")
}
}
// TestBootstrapToken_CORSPreflightAllowsHeader pins the CORS contract: a
// browser submitting from a configured cross-origin must succeed at
// preflight for the X-Bootstrap-Token header. The default same-origin
// case doesn't trigger preflight, so this test specifically configures
// CORS to exercise the preflight path.
func TestBootstrapToken_CORSPreflightAllowsHeader(t *testing.T) {
srv := testServer(t)
srv.SetCORSOrigins("https://other.example.com")
req := httptest.NewRequest("OPTIONS", "/api/v1/auth/bootstrap", nil)
req.Header.Set("Origin", "https://other.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "X-Bootstrap-Token, Content-Type")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK && rr.Code != http.StatusNoContent {
t.Fatalf("preflight: expected 200/204, got %d: %s", rr.Code, rr.Body.String())
}
allowed := rr.Header().Get("Access-Control-Allow-Headers")
if allowed == "" {
t.Fatal("preflight response missing Access-Control-Allow-Headers")
}
// Allow header is comma-separated; chi/cors echoes the request set if
// they're all in the configured list. Check the bootstrap header
// specifically.
if !containsHeader(allowed, "X-Bootstrap-Token") {
t.Fatalf("Access-Control-Allow-Headers missing X-Bootstrap-Token: %q", allowed)
}
}
// containsHeader does a case-insensitive comma-separated lookup.
func containsHeader(allowed, want string) bool {
// Lowercase compare on the simple case — preflight responses typically
// lower-case the echo. Use strings.EqualFold-style match per token.
for _, part := range splitCSV(allowed) {
if equalFoldASCII(part, want) {
return true
}
}
return false
}
func splitCSV(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
out = append(out, trimSpaceASCII(s[start:i]))
start = i + 1
}
}
out = append(out, trimSpaceASCII(s[start:]))
return out
}
func trimSpaceASCII(s string) string {
i, j := 0, len(s)
for i < j && (s[i] == ' ' || s[i] == '\t') {
i++
}
for j > i && (s[j-1] == ' ' || s[j-1] == '\t') {
j--
}
return s[i:j]
}
func equalFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if 'A' <= ca && ca <= 'Z' {
ca += 32
}
if 'A' <= cb && cb <= 'Z' {
cb += 32
}
if ca != cb {
return false
}
}
return true
}