Ctrl K
ComunidadGuías…

internal/server/security_regression_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"bytes"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/X4Applegate/caddyui/internal/auth"
	"github.com/X4Applegate/caddyui/internal/caddy"
	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/models"
)

// Security regression tests (v2.57.1). They drive the REAL router with four
// accounts — an admin, two customer-level users (alice, bob) and a read-only
// viewer — against a fake Caddy admin API, so they exercise the actual
// middleware stacking rather than individual handlers.
//
// Each test asserts the FIXED behaviour, so it fails on the vulnerable code.

const (
	secKeyMaterial = "SEC_PRIVATE_KEY_MATERIAL"
	secDNSToken    = "SEC_CF_DNS_TOKEN"
)

type secEnv struct {
	s      *Server
	h      http.Handler
	db     *sql.DB
	ids    map[string]int64
	tokens map[string]string
	mu     sync.Mutex
	reqs   []string // "METHOD path :: body" seen by the fake Caddy admin API
	// adaptResponse, when set, is what the fake Caddy returns from /adapt.
	adaptResponse string
}

func newSecEnv(t *testing.T) *secEnv {
	t.Helper()
	e := &secEnv{ids: map[string]int64{}, tokens: map[string]string{}}
	dbPath := filepath.Join(t.TempDir(), "caddyui.db")
	conn, err := appdb.Open(dbPath)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	e.db = conn

	fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		e.mu.Lock()
		e.reqs = append(e.reqs, r.Method+" "+r.URL.RequestURI()+" :: "+string(b))
		e.mu.Unlock()
		w.Header().Set("Content-Type", "application/json")
		switch {
		case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/config"):
			fmt.Fprintf(w, `{"apps":{"tls":{"certificates":{"load_pem":[{"certificate":"CERT","key":"%s"}]},"automation":{"policies":[{"issuers":[{"module":"acme","challenges":{"dns":{"provider":{"name":"cloudflare","api_token":"%s"}}}}]}]}},"http":{"servers":{"srv0":{"routes":[]}}}}}`, secKeyMaterial, secDNSToken)
		case r.URL.Path == "/adapt":
			e.mu.Lock()
			resp := e.adaptResponse
			e.mu.Unlock()
			if resp == "" {
				resp = `{"result":{"apps":{"http":{"servers":{"srv0":{"routes":[]}}}}}}`
			}
			fmt.Fprint(w, resp)
		default:
			fmt.Fprint(w, `{}`)
		}
	}))
	t.Cleanup(fake.Close)

	if _, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "primary", AdminURL: fake.URL, Type: models.CaddyServerTypeManaged}); err != nil {
		t.Fatal(err)
	}
	tpls, err := parseTemplates(os.DirFS("../../web/templates"))
	if err != nil {
		t.Fatal(err)
	}
	e.s = &Server{DB: conn, Caddy: caddy.New(fake.URL, "", ""), Templates: tpls, Static: os.DirFS("../../web/static"), DBPath: dbPath,
		healthFailures: map[int64]int{}, appHealth: map[int64]appHealthEntry{}, runtimeLogTimers: map[int64]*time.Timer{}}
	e.h = e.s.Routes()

	hash, _ := auth.HashPassword("password-123")
	for _, u := range []struct{ name, role string }{{"admin", models.RoleAdmin}, {"alice", models.RoleUser}, {"bob", models.RoleUser}, {"viewer", models.RoleView}} {
		id, err := models.CreateUser(conn, u.name+"@t", hash, u.name, u.role)
		if err != nil {
			t.Fatal(err)
		}
		e.ids[u.name] = id
		tok, _, err := auth.CreateSession(conn, id)
		if err != nil {
			t.Fatal(err)
		}
		e.tokens[u.name] = tok
	}
	return e
}

// do performs a request as one of the fixture accounts through the real router.
func (e *secEnv) do(t *testing.T, who, method, path string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	var body io.Reader
	if form != nil {
		body = strings.NewReader(form.Encode())
	}
	req := httptest.NewRequest(method, path, body)
	req.RemoteAddr = "172.18.0.9:5555"
	if form != nil {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	if tok, ok := e.tokens[who]; ok {
		req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: tok})
		if method != http.MethodGet {
			req.Header.Set("X-CSRF-Token", e.s.csrfTokenFor(tok))
		}
	}
	rec := httptest.NewRecorder()
	e.h.ServeHTTP(rec, req)
	return rec
}

// bearer performs a request authenticated with an API token.
func (e *secEnv) bearer(t *testing.T, raw, method, path string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	var body io.Reader
	if form != nil {
		body = strings.NewReader(form.Encode())
	}
	req := httptest.NewRequest(method, path, body)
	req.RemoteAddr = "172.18.0.9:5555"
	if form != nil {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	req.Header.Set("Authorization", "Bearer "+raw)
	rec := httptest.NewRecorder()
	e.h.ServeHTTP(rec, req)
	return rec
}

// newToken stores an API token of the given scope owned by the admin and
// returns its raw value.
func (e *secEnv) newToken(t *testing.T, name, scope string) string {
	t.Helper()
	raw := "cadu_" + name + "_test_token"
	sum := sha256.Sum256([]byte(raw))
	if _, err := models.CreateAPIToken(e.db, e.ids["admin"], name, hex.EncodeToString(sum[:]), scope, nil); err != nil {
		t.Fatal(err)
	}
	return raw
}

func (e *secEnv) bobHost(t *testing.T) int64 {
	t.Helper()
	id, err := models.CreateProxyHost(e.db, 1, e.ids["bob"], &models.ProxyHost{
		Domains: "bob.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.5", ForwardPort: 8080, Enabled: true,
		HTTPBasicAuthUpstream: "svc:UPSTREAM_BASIC_SECRET", APIKeyValue: "BOB_API_KEY",
	})
	if err != nil {
		t.Fatal(err)
	}
	return id
}

// --- admin-only surfaces -------------------------------------------------

// The database, the live Caddy config and the snapshots contain every stored
// credential, certificate private keys and DNS API tokens. Only admins may reach
// them. Before v2.57.1 /backup sat in the requireWrite group (any non-viewer),
// and /caddy-config, /import and the snapshot download were open to every role.
func TestSecurityConfigAndBackupSurfacesAreAdminOnly(t *testing.T) {
	e := newSecEnv(t)
	if _, err := models.CreateSnapshot(e.db, 1, models.SnapshotSourceManual, "s", `{"k":"`+secKeyMaterial+`"}`); err != nil {
		t.Fatal(err)
	}
	routes := []struct{ method, path string }{
		{http.MethodGet, "/backup"},
		{http.MethodGet, "/caddy-config"},
		{http.MethodGet, "/import"},
		{http.MethodPost, "/import"},
		{http.MethodGet, "/snapshots"},
		{http.MethodGet, "/snapshots/1/download"},
		{http.MethodGet, "/snapshots/1/diff"},
		{http.MethodPost, "/snapshots"},
		{http.MethodPost, "/snapshots/upload"},
		{http.MethodPost, "/snapshots/auto"},
		{http.MethodPost, "/snapshots/1/restore"},
		{http.MethodPost, "/snapshots/1/delete"},
		{http.MethodGet, "/certificates/import/porkbun"},
		{http.MethodPost, "/certificates/import/porkbun"},
		{http.MethodGet, "/api/notifier-status"},
	}
	for _, rt := range routes {
		for _, who := range []string{"alice", "viewer"} {
			var form url.Values
			if rt.method == http.MethodPost {
				form = url.Values{}
			}
			rec := e.do(t, who, rt.method, rt.path, form)
			if rec.Code != http.StatusForbidden {
				t.Errorf("%s %s %s -> %d, want 403", who, rt.method, rt.path, rec.Code)
			}
			if strings.Contains(rec.Body.String(), secKeyMaterial) || strings.Contains(rec.Body.String(), secDNSToken) {
				t.Errorf("%s %s %s leaked secret material to a non-admin", who, rt.method, rt.path)
			}
		}
	}
	// The admin still gets them (not forbidden; the page itself may need Caddy).
	for _, p := range []string{"/backup", "/caddy-config", "/snapshots"} {
		if rec := e.do(t, "admin", http.MethodGet, p, nil); rec.Code == http.StatusForbidden {
			t.Errorf("admin GET %s -> 403, must still be allowed", p)
		}
	}
	// The navigation no longer advertises pages a non-admin cannot open.
	body := e.do(t, "alice", http.MethodGet, "/proxy-hosts", nil).Body.String()
	for _, link := range []string{`href="/caddy-config"`, `href="/snapshots"`, `href="/import"`} {
		if strings.Contains(body, link) {
			t.Errorf("a non-admin's navigation still links to %s", link)
		}
	}
}

// --- per-row ownership ---------------------------------------------------

// Every single-host handler checks ownership; the export, maintenance, health
// and expectation endpoints did not, so any account could read another tenant's
// upstream credentials or change their host by ID.
func TestSecuritySingleHostHandlersEnforceOwnership(t *testing.T) {
	e := newSecEnv(t)
	id := e.bobHost(t)
	base := fmt.Sprintf("/proxy-hosts/%d", id)

	for _, c := range []struct{ method, suffix string }{
		{http.MethodGet, "/export.json"},
		{http.MethodGet, "/export.caddyfile"},
		{http.MethodGet, "/health"},
		{http.MethodPost, "/maintenance"},
		{http.MethodPost, "/expectations/run"},
	} {
		for _, who := range []string{"alice", "viewer"} {
			var form url.Values
			if c.method == http.MethodPost {
				form = url.Values{}
			}
			rec := e.do(t, who, c.method, base+c.suffix, form)
			if rec.Code != http.StatusForbidden {
				t.Errorf("%s %s %s -> %d, want 403", who, c.method, base+c.suffix, rec.Code)
			}
			body := rec.Body.String()
			if strings.Contains(body, "UPSTREAM_BASIC_SECRET") || strings.Contains(body, "BOB_API_KEY") || strings.Contains(body, "bob.example.test") {
				t.Errorf("%s %s %s leaked another tenant's host data", who, c.method, base+c.suffix)
			}
		}
	}
	if ph, _ := models.GetProxyHost(e.db, id); ph.MaintenanceMode {
		t.Error("another tenant's host was put into maintenance mode")
	}

	// The owner and the admin still can.
	for _, who := range []string{"bob", "admin"} {
		if rec := e.do(t, who, http.MethodGet, base+"/export.json", nil); rec.Code != http.StatusOK {
			t.Errorf("%s export.json -> %d, want 200", who, rec.Code)
		}
	}
}

func TestSecurityCertificateInspectIsScopedToVisibleCertificates(t *testing.T) {
	e := newSecEnv(t)
	cid, err := models.CreateCertificate(e.db, 1, e.ids["bob"], &models.Certificate{
		Name: "BOB-PRIVATE-CERT", Domains: "bob-secret.example.test", Source: models.CertSourcePath,
		CertPath: "/certs/bob.crt", KeyPath: "/certs/bob.key",
	})
	if err != nil {
		t.Fatal(err)
	}
	path := fmt.Sprintf("/certificates/%d/inspect", cid)
	rec := e.do(t, "alice", http.MethodGet, path, nil)
	if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "BOB-PRIVATE-CERT") || strings.Contains(rec.Body.String(), "/certs/bob.key") {
		t.Errorf("alice inspecting bob's certificate -> %d (leaked=%v), want 404", rec.Code, strings.Contains(rec.Body.String(), "BOB-PRIVATE-CERT"))
	}
	if rec := e.do(t, "bob", http.MethodGet, path, nil); rec.Code != http.StatusOK {
		t.Errorf("owner inspecting their own certificate -> %d, want 200", rec.Code)
	}
}

// --- REST API certificate list -------------------------------------------

// The list endpoint returned key_pem for every global (admin-owned)
// certificate to every role, including read-only tokens, while the single-row
// GET correctly refused. Secrets now follow canManageOwned in both.
func TestSecurityAPICertificateListNeverReturnsKeysTheCallerCannotManage(t *testing.T) {
	e := newSecEnv(t)
	if _, err := models.CreateCertificate(e.db, 1, 0, &models.Certificate{
		Name: "wildcard", Domains: "*.example.test", Source: models.CertSourcePEM,
		CertPEM: "CERTPEM", KeyPEM: "-----BEGIN PRIVATE KEY-----ADMIN_GLOBAL_KEY-----END PRIVATE KEY-----",
		KeyPath: "/secret/path.key",
	}); err != nil {
		t.Fatal(err)
	}
	ro := e.newToken(t, "readonly", models.TokenScopeReadOnly)

	check := func(label string, rec *httptest.ResponseRecorder, wantKey bool) {
		t.Helper()
		if rec.Code != http.StatusOK {
			t.Fatalf("%s -> %d: %s", label, rec.Code, rec.Body.String())
		}
		has := strings.Contains(rec.Body.String(), "ADMIN_GLOBAL_KEY") || strings.Contains(rec.Body.String(), "/secret/path.key")
		if has != wantKey {
			t.Errorf("%s: key material present=%v, want %v", label, has, wantKey)
		}
	}
	check("admin list", e.do(t, "admin", http.MethodGet, "/api/v1/certificates", nil), true)
	check("alice list", e.do(t, "alice", http.MethodGet, "/api/v1/certificates", nil), false)
	check("viewer list", e.do(t, "viewer", http.MethodGet, "/api/v1/certificates", nil), false)
	check("read-only token list", e.bearer(t, ro, http.MethodGet, "/api/v1/certificates", nil), false)
	// The rest of the row is still listed: only the secrets are withheld.
	if body := e.do(t, "alice", http.MethodGet, "/api/v1/certificates", nil).Body.String(); !strings.Contains(body, "wildcard") {
		t.Error("the certificate itself should still be listed for a non-admin")
	}
}

// --- API token scope -----------------------------------------------------

// proxy_write was only enforced inside the requireWrite route group, so a
// leaked proxy-host CI token could mint a full-scope token, create an admin and
// change settings through routes outside that group.
func TestSecurityAPITokenScopeIsEnforcedOnEveryRoute(t *testing.T) {
	e := newSecEnv(t)
	pw := e.newToken(t, "proxywrite", models.TokenScopeProxyWrite)
	ro := e.newToken(t, "readonly", models.TokenScopeReadOnly)
	full := e.newToken(t, "full", models.TokenScopeFull)

	deny := func(label, raw, method, path string, form url.Values) {
		t.Helper()
		if rec := e.bearer(t, raw, method, path, form); rec.Code != http.StatusForbidden {
			t.Errorf("%s -> %d, want 403", label, rec.Code)
		}
	}
	deny("proxy_write token creating an API token", pw, http.MethodPost, "/api-tokens", url.Values{"name": {"x"}, "scopes": {"full"}})
	deny("proxy_write token creating a user", pw, http.MethodPost, "/users", url.Values{"email": {"evil@t"}, "name": {"evil"}, "password": {"password-xyz"}, "password_confirm": {"password-xyz"}, "role": {"admin"}})
	deny("proxy_write token changing settings", pw, http.MethodPost, "/settings", url.Values{"site_title": {"pwned"}})
	deny("proxy_write token on a non-API write path", pw, http.MethodPost, "/proxy-hosts", url.Values{})
	deny("read_only token creating an API token", ro, http.MethodPost, "/api-tokens", url.Values{"name": {"x"}})
	// Even a full-scope token cannot manage tokens: that needs a browser session.
	deny("full token creating an API token", full, http.MethodPost, "/api-tokens", url.Values{"name": {"x"}, "scopes": {"full"}})
	deny("full token revoking an API token", full, http.MethodPost, "/api-tokens/1/revoke", url.Values{})

	var n int
	_ = e.db.QueryRow(`SELECT COUNT(*) FROM api_tokens`).Scan(&n)
	if n != 3 {
		t.Errorf("api_tokens rows = %d, want the 3 fixtures only", n)
	}
	if u, _ := models.GetUserByEmail(e.db, "evil@t"); u != nil {
		t.Error("a proxy_write token created a user")
	}

	// What the scopes are for still works.
	if rec := e.bearer(t, pw, http.MethodGet, "/api/v1/proxy-hosts", nil); rec.Code != http.StatusOK {
		t.Errorf("proxy_write token listing proxy hosts -> %d, want 200", rec.Code)
	}
	if rec := e.bearer(t, ro, http.MethodGet, "/api/v1/proxy-hosts", nil); rec.Code != http.StatusOK {
		t.Errorf("read_only token listing proxy hosts -> %d, want 200", rec.Code)
	}
}

// --- sign-in brute force --------------------------------------------------

func (e *secEnv) postForm(t *testing.T, path, remoteAddr string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
	req.RemoteAddr = remoteAddr
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	e.h.ServeHTTP(rec, req)
	return rec
}

// The lockout counted a column the failure row never wrote to, so
// max_login_attempts had never locked anything out.
func TestSecurityLoginLockoutActuallyLocksOut(t *testing.T) {
	e := newSecEnv(t)
	if err := models.SetSetting(e.db, settingMaxLoginAttempts, "3"); err != nil {
		t.Fatal(err)
	}
	wrong := url.Values{"email": {"admin@t"}, "password": {"wrong"}}
	locked := func(addr string) bool {
		return strings.Contains(e.postForm(t, "/login", addr, wrong).Body.String(), "Too many failed")
	}
	for i := 1; i <= 3; i++ {
		if locked("203.0.113.9:4444") {
			t.Fatalf("locked after only %d failure(s); the limit is 3", i-1)
		}
	}
	if !locked("203.0.113.9:4444") {
		t.Fatal("the 4th attempt from the same address was not locked out")
	}
	// Another address is unaffected — the lockout is per client, not global.
	if locked("203.0.113.10:4444") {
		t.Fatal("a different client address was locked out too")
	}
	// Even the right password is refused while locked out.
	good := url.Values{"email": {"admin@t"}, "password": {"password-123"}}
	if !strings.Contains(e.postForm(t, "/login", "203.0.113.9:4444", good).Body.String(), "Too many failed") {
		t.Fatal("the correct password was accepted during a lockout")
	}
}

// Wrong second-factor codes were retried without limit on one pending token,
// and a fresh password login reset nothing, so a known password bought
// unlimited guesses at a 6-digit code.
func TestSecurityTOTPGuessesAreLimited(t *testing.T) {
	e := newSecEnv(t)
	const secret = "JBSWY3DPEHPK3PXP"
	if err := models.SetUserTOTP(e.db, e.ids["admin"], secret, true); err != nil {
		t.Fatal(err)
	}
	login := func() string {
		rec := e.postForm(t, "/login", "203.0.113.50:1", url.Values{"email": {"admin@t"}, "password": {"password-123"}})
		if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/login/totp?t=") {
			return "" // refused
		}
		return strings.TrimPrefix(rec.Header().Get("Location"), "/login/totp?t=")
	}
	guess := func(tok, code string) string {
		return e.postForm(t, "/login/totp", "203.0.113.50:1", url.Values{"token": {tok}, "code": {code}}).Body.String()
	}

	// One token allows a handful of guesses, then dies.
	tok := login()
	if tok == "" {
		t.Fatal("first-factor login should hand out a TOTP step")
	}
	for i := 1; i < maxTOTPAttemptsPerToken; i++ {
		if body := guess(tok, "000000"); !strings.Contains(body, "Invalid code") {
			t.Fatalf("guess %d: want 'Invalid code', got %q", i, excerpt(body, "code"))
		}
	}
	if body := guess(tok, "000000"); !strings.Contains(body, "Too many incorrect codes") {
		t.Fatalf("guess %d should exhaust the token, got %q", maxTOTPAttemptsPerToken, excerpt(body, "Too many"))
	}
	if body := guess(tok, "000000"); !strings.Contains(body, "Session expired") {
		t.Fatal("an exhausted token must not be usable again")
	}

	// A fresh password login does not reset the budget: after enough failures
	// the account itself is locked out of the second-factor step.
	for round := 0; round < 3 && login() != ""; round++ {
		t2 := login()
		for i := 0; t2 != "" && i < maxTOTPAttemptsPerToken; i++ {
			guess(t2, "000000")
		}
	}
	if login() != "" {
		t.Fatal("an account with many failed second-factor codes can still start a new TOTP step")
	}
}

// --- sessions after a credential change ----------------------------------

// A stolen session used to survive the password reset meant to evict it.
func TestSecurityPasswordChangeSignsOtherSessionsOut(t *testing.T) {
	e := newSecEnv(t)
	// A second device for alice.
	otherTok, _, err := auth.CreateSession(e.db, e.ids["alice"])
	if err != nil {
		t.Fatal(err)
	}
	alive := func(tok string) bool {
		u, _ := auth.UserFromSession(e.db, tok)
		return u != nil
	}
	if !alive(otherTok) || !alive(e.tokens["alice"]) {
		t.Fatal("setup: both of alice's sessions should be valid")
	}

	// Self-service change from the browser holding e.tokens["alice"].
	rec := e.do(t, "alice", http.MethodPost, "/profile", url.Values{
		"action": {"change_password"}, "current_password": {"password-123"},
		"new_password": {"brand-new-pass-1"}, "confirm_password": {"brand-new-pass-1"},
	})
	if rec.Code != http.StatusFound {
		t.Fatalf("profile change_password -> %d", rec.Code)
	}
	if alive(otherTok) {
		t.Error("another device's session survived the password change")
	}
	if !alive(e.tokens["alice"]) {
		t.Error("the browser that changed the password was signed out too")
	}

	// A reset/admin-initiated change (models.UpdateUserPassword) signs out everywhere.
	h, _ := auth.HashPassword("another-pass-123")
	if err := models.UpdateUserPassword(e.db, e.ids["alice"], h); err != nil {
		t.Fatal(err)
	}
	if alive(e.tokens["alice"]) {
		t.Error("a password reset left the account's session valid")
	}
	// Other accounts are untouched.
	if !alive(e.tokens["bob"]) {
		t.Error("an unrelated account was signed out")
	}
}

// --- emailed links and first-run setup -----------------------------------

// POST /forgot-password is unauthenticated, and the emailed link used to be
// built from the request's Host header: a forged Host got a working reset token
// mailed to the victim, pointing at the attacker.
func TestSecurityEmailedLinksIgnoreTheRequestHostWhenConfigured(t *testing.T) {
	e := newSecEnv(t)
	req := func(host string) *http.Request {
		r := httptest.NewRequest(http.MethodPost, "/forgot-password", nil)
		r.Host = host
		r.RemoteAddr = "203.0.113.200:1"
		return r
	}

	// Configured via the environment: the Host header is irrelevant.
	t.Setenv("CADDYUI_PUBLIC_URL", "https://caddyui.example.com/")
	if got := e.s.publicBaseURL(req("evil.attacker.test")); got != "https://caddyui.example.com" {
		t.Errorf("with CADDYUI_PUBLIC_URL set, base = %q, want https://caddyui.example.com", got)
	}

	// Not set: fall back to the configured OIDC redirect URL's origin.
	t.Setenv("CADDYUI_PUBLIC_URL", "")
	if err := models.SetSetting(e.db, settingOIDCRedirectURL, "https://sso.example.com/auth/oidc/callback"); err != nil {
		t.Fatal(err)
	}
	if got := e.s.publicBaseURL(req("evil.attacker.test")); got != "https://sso.example.com" {
		t.Errorf("with an OIDC redirect URL, base = %q, want https://sso.example.com", got)
	}

	// Nothing configured: the request is the only source (unchanged), but a
	// forwarded scheme is trusted only from a trusted proxy.
	if err := models.SetSetting(e.db, settingOIDCRedirectURL, ""); err != nil {
		t.Fatal(err)
	}
	direct := req("ui.example.com")
	direct.Header.Set("X-Forwarded-Proto", "https") // from a public peer: ignored
	if got := e.s.publicBaseURL(direct); got != "http://ui.example.com" {
		t.Errorf("untrusted X-Forwarded-Proto was honoured: %q", got)
	}
	viaProxy := req("ui.example.com")
	viaProxy.RemoteAddr = "172.18.0.9:5555" // private peer = trusted proxy
	viaProxy.Header.Set("X-Forwarded-Proto", "https")
	if got := e.s.publicBaseURL(viaProxy); got != "https://ui.example.com" {
		t.Errorf("proxied https request base = %q, want https://ui.example.com", got)
	}

	for in, want := range map[string]string{
		"https://a.example.com:8443/some/path?x=1": "https://a.example.com:8443",
		"http://a.example.com":                     "http://a.example.com",
		"ftp://a.example.com":                      "",
		"https://user:pw@a.example.com":            "",
		"//a.example.com":                          "",
		"not a url":                                "",
		"":                                         "",
	} {
		if got := cleanBaseURL(in); got != want {
			t.Errorf("cleanBaseURL(%q) = %q, want %q", in, got, want)
		}
	}
}

func newEmptySetupServer(t *testing.T) *Server {
	t.Helper()
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	return newRenderingTestServer(t, conn)
}

func setupRequest(email, pw string) *http.Request {
	return postForm0("/setup", url.Values{"email": {email}, "name": {"x"}, "password": {pw}, "password_confirm": {pw}})
}

func postForm0(path string, v url.Values) *http.Request {
	r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(v.Encode()))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return r
}

func TestSecuritySetupEnforcesPasswordMinimumServerSide(t *testing.T) {
	s := newEmptySetupServer(t)
	rec := httptest.NewRecorder()
	s.postSetup(rec, setupRequest("admin@example.com", "short"))
	if n, _ := models.CountUsers(s.DB); n != 0 {
		t.Fatalf("a 5-character password created %d user(s); the minimum is 8", n)
	}
	if !strings.Contains(rec.Body.String(), "at least 8 characters") {
		t.Errorf("the form should explain the minimum, got %q", excerpt(rec.Body.String(), "characters"))
	}
	rec = httptest.NewRecorder()
	s.postSetup(rec, setupRequest("admin@example.com", "long-enough-pass"))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("a valid setup -> %d, want 303", rec.Code)
	}
	if n, _ := models.CountUsers(s.DB); n != 1 {
		t.Fatalf("users after a valid setup = %d, want 1", n)
	}
}

// Two simultaneous first-run requests used to be able to both see zero users
// and both create an admin.
func TestSecuritySetupCreatesExactlyOneAdminUnderConcurrency(t *testing.T) {
	s := newEmptySetupServer(t)
	var wg sync.WaitGroup
	for i := 0; i < 12; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			s.postSetup(httptest.NewRecorder(), setupRequest(fmt.Sprintf("admin%d@example.com", i), "long-enough-pass"))
		}(i)
	}
	wg.Wait()
	if n, _ := models.CountUsers(s.DB); n != 1 {
		t.Fatalf("%d concurrent setups created %d admins, want exactly 1", 12, n)
	}
}

// attachSession adds a fixture account's session cookie (and CSRF header for
// writes) to a hand-built request.
func (e *secEnv) attachSession(req *http.Request, who string) {
	req.RemoteAddr = "172.18.0.9:5555"
	tok := e.tokens[who]
	req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: tok})
	if req.Method != http.MethodGet {
		req.Header.Set("X-CSRF-Token", e.s.csrfTokenFor(tok))
	}
}

// --- sync serialization ---------------------------------------------------

// syncCaddy points the shared s.Caddy at one server for its whole duration, so
// concurrent syncs of different servers crossed over: a reproduction pushed one
// server's routes (with their certificate keys and basic-auth hashes) to
// another server in most iterations.
func TestSecurityConcurrentSyncsNeverPushOneServersRoutesToAnother(t *testing.T) {
	e := newSecEnv(t)
	var mu sync.Mutex
	crossA, crossB := 0, 0
	mk := func(other string, cross *int) *httptest.Server {
		return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			b, _ := io.ReadAll(r.Body)
			if r.Method != http.MethodGet && strings.Contains(string(b), other) {
				mu.Lock()
				*cross++
				mu.Unlock()
			}
			w.Header().Set("Content-Type", "application/json")
			if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/config") {
				fmt.Fprint(w, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
				return
			}
			fmt.Fprint(w, `{}`)
		}))
	}
	A := mk("b-only.example.test", &crossA)
	B := mk("a-only.example.test", &crossB)
	defer A.Close()
	defer B.Close()
	if _, err := e.db.Exec(`UPDATE caddy_servers SET admin_url=? WHERE id=1`, A.URL); err != nil {
		t.Fatal(err)
	}
	if _, err := models.CreateCaddyServer(e.db, &models.CaddyServer{Name: "edge", AdminURL: B.URL, Type: models.CaddyServerTypeManaged}); err != nil {
		t.Fatal(err)
	}
	for _, h := range []struct {
		server int64
		domain string
	}{{1, "a-only.example.test"}, {2, "b-only.example.test"}} {
		if _, err := models.CreateProxyHost(e.db, h.server, 0, &models.ProxyHost{Domains: h.domain, ForwardScheme: "http", ForwardHost: "10.0.0.1", ForwardPort: 80, Enabled: true}); err != nil {
			t.Fatal(err)
		}
	}
	var wg sync.WaitGroup
	for i := 0; i < 40; i++ {
		wg.Add(2)
		go func() { defer wg.Done(); _ = e.s.syncCaddy(1, false) }()
		go func() { defer wg.Done(); _ = e.s.syncCaddy(2, false) }()
	}
	wg.Wait()
	mu.Lock()
	defer mu.Unlock()
	if crossA != 0 || crossB != 0 {
		t.Fatalf("cross-server config pushes: B's routes reached server A %d times, A's routes reached server B %d times (want 0/0)", crossA, crossB)
	}
}

// --- fleet copies --------------------------------------------------------

// A copy of a customer's host used to adopt ANY target row with the same
// domains, overwrite it and reset its owner: one customer could take over
// another's host on a second server just by saving a host with that domain and
// ticking "Also deploy to".
func TestSecurityFleetCopyOfATenantHostNeverAdoptsAnotherOwnersRow(t *testing.T) {
	e := newSecEnv(t)
	var adminURL string
	_ = e.db.QueryRow(`SELECT admin_url FROM caddy_servers WHERE id=1`).Scan(&adminURL)
	if _, err := models.CreateCaddyServer(e.db, &models.CaddyServer{Name: "edge", AdminURL: adminURL, Type: models.CaddyServerTypeManaged}); err != nil {
		t.Fatal(err)
	}
	victim, err := models.CreateProxyHost(e.db, 2, e.ids["bob"], &models.ProxyHost{
		Domains: "victim.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.5", ForwardPort: 8080, Enabled: true})
	if err != nil {
		t.Fatal(err)
	}
	adminHost, err := models.CreateProxyHost(e.db, 2, 0, &models.ProxyHost{
		Domains: "admin-site.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.6", ForwardPort: 9090, Enabled: true})
	if err != nil {
		t.Fatal(err)
	}

	for _, domain := range []string{"victim.example.test", "admin-site.example.test"} {
		e.do(t, "alice", http.MethodPost, "/proxy-hosts", url.Values{
			"domains": {domain}, "forward_scheme": {"http"}, "forward_host": {"203.0.113.99"},
			"forward_port": {"80"}, "enabled": {"on"}, "deploy_to": {"2"}})
	}

	check := func(id int64, wantHost string, wantOwner sql.NullInt64) {
		t.Helper()
		var fh string
		var owner sql.NullInt64
		if err := e.db.QueryRow(`SELECT forward_host, owner_id FROM proxy_hosts WHERE id=?`, id).Scan(&fh, &owner); err != nil {
			t.Fatal(err)
		}
		if fh != wantHost || owner != wantOwner {
			t.Errorf("host %d on the target is now forward_host=%q owner=%v, want %q owner=%v — another account's row was overwritten", id, fh, owner, wantHost, wantOwner)
		}
	}
	check(victim, "10.0.0.5", sql.NullInt64{Int64: e.ids["bob"], Valid: true})
	check(adminHost, "10.0.0.6", sql.NullInt64{})

	// An admin-owned source keeps today's behaviour: it adopts and updates the
	// same-domain row on the target.
	srcID, err := models.CreateProxyHost(e.db, 1, 0, &models.ProxyHost{
		Domains: "admin-site.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.77", ForwardPort: 9090, Enabled: true})
	if err != nil {
		t.Fatal(err)
	}
	src, _ := models.GetProxyHost(e.db, srcID)
	e.s.crossDeployProxyHost("admin@t", 1, src, []int64{2})
	check(adminHost, "10.0.0.77", sql.NullInt64{})
}

// Certificate export writes a private key to a directory of the requester's
// choosing and source push copies a domain's key out of Caddy's storage to
// other servers; both act with CaddyUI's authority, so they are admin-only.
func TestSecurityCertificateExportAndFleetPushAreAdminOnly(t *testing.T) {
	e := newSecEnv(t)
	managed := func(extra url.Values) url.Values {
		f := url.Values{"name": {"wild"}, "domains": {"*.victim.example.test"}, "source": {"managed"},
			"dns_provider": {"cloudflare"}}
		for k, v := range extra {
			f[k] = v
		}
		return f
	}
	for label, extra := range map[string]url.Values{
		"export to a directory": {"export_dir": {"/data"}, "export_cert_file": {"c.pem"}, "export_key_file": {"caddyui.db"}},
		"source push":           {"fleet_distribution_mode": {models.CertFleetDistributionSourcePush}, "deploy_to": {"2"}},
	} {
		rec := e.do(t, "alice", http.MethodPost, "/certificates", managed(extra))
		if rec.Code == http.StatusSeeOther || !strings.Contains(rec.Body.String(), "admin-only") {
			t.Errorf("alice: %s -> %d, want a refusal saying it is admin-only (got %q)", label, rec.Code, excerpt(rec.Body.String(), "admin"))
		}
	}
	if rows, _ := models.ListCertificates(e.db, 1); len(rows) != 0 {
		t.Errorf("%d certificate(s) were stored by the refused requests", len(rows))
	}
	// Copying a certificate to other servers is ignored for a non-admin.
	r := httptest.NewRequest(http.MethodPost, "/certificates", strings.NewReader(url.Values{"deploy_to": {"2"}}.Encode()))
	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	_ = r.ParseForm()
	if got := e.s.certificateDeployTargets(r); len(got) != 0 {
		t.Errorf("a non-admin's certificate deploy targets = %v, want none", got)
	}
}

// --- hostname hijack and imports -----------------------------------------

func (e *secEnv) json(t *testing.T, who, method, path string, body any) *httptest.ResponseRecorder {
	t.Helper()
	b, _ := json.Marshal(body)
	req := httptest.NewRequest(method, path, strings.NewReader(string(b)))
	req.Header.Set("Content-Type", "application/json")
	e.attachSession(req, who)
	rec := httptest.NewRecorder()
	e.h.ServeHTTP(rec, req)
	return rec
}

// The UI refuses a domain another host or redirect already holds; the REST API
// did not. Caddy keeps the first matching route and the newest row sorts first,
// so a duplicate silently took over the existing host's traffic.
func TestSecurityRESTAPIRefusesDuplicateDomains(t *testing.T) {
	e := newSecEnv(t)
	e.bobHost(t) // bob.example.test

	dup := map[string]any{"domains": "bob.example.test", "forward_scheme": "http", "forward_host": "203.0.113.9", "forward_port": 80, "enabled": true}
	if rec := e.json(t, "alice", http.MethodPost, "/api/v1/proxy-hosts", dup); rec.Code != http.StatusConflict {
		t.Errorf("duplicate proxy host via REST -> %d, want 409: %s", rec.Code, rec.Body.String())
	}
	if rec := e.json(t, "alice", http.MethodPost, "/api/v1/redirection-hosts", map[string]any{
		"domains": "bob.example.test", "forward_domain": "evil.example.test", "enabled": true}); rec.Code != http.StatusConflict {
		t.Errorf("duplicate redirection via REST -> %d, want 409: %s", rec.Code, rec.Body.String())
	}

	// A different domain is fine, and editing one's own host onto a taken
	// domain is refused.
	ok := map[string]any{"domains": "alice.example.test", "forward_scheme": "http", "forward_host": "203.0.113.9", "forward_port": 80, "enabled": true}
	rec := e.json(t, "alice", http.MethodPost, "/api/v1/proxy-hosts", ok)
	if rec.Code != http.StatusCreated {
		t.Fatalf("a unique domain via REST -> %d: %s", rec.Code, rec.Body.String())
	}
	var created struct {
		ID int64 `json:"id"`
	}
	_ = json.Unmarshal(rec.Body.Bytes(), &created)
	rec = e.json(t, "alice", http.MethodPut, fmt.Sprintf("/api/v1/proxy-hosts/%d", created.ID), map[string]any{"domains": "bob.example.test"})
	if rec.Code != http.StatusConflict {
		t.Errorf("editing onto a taken domain via REST -> %d, want 409: %s", rec.Code, rec.Body.String())
	}
}

func multipartUpload(t *testing.T, field, content string) (*strings.Reader, string) {
	t.Helper()
	const boundary = "----secboundary"
	body := "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + field + "\"; filename=\"host.json\"\r\nContent-Type: application/json\r\n\r\n" + content + "\r\n--" + boundary + "--\r\n"
	return strings.NewReader(body), "multipart/form-data; boundary=" + boundary
}

// An imported file is attacker-controlled. A DNS record ID in it made a later
// delete of the host delete THAT record at the DNS provider using the admin's
// credentials; a duplicate domain hijacked another host.
func TestSecurityProxyHostImportDropsRuntimeStateAndRefusesDuplicates(t *testing.T) {
	e := newSecEnv(t)
	e.bobHost(t)
	upload := func(who, content string) *httptest.ResponseRecorder {
		body, ct := multipartUpload(t, "config_file", content)
		req := httptest.NewRequest(http.MethodPost, "/proxy-hosts/import", body)
		req.Header.Set("Content-Type", ct)
		e.attachSession(req, who)
		rec := httptest.NewRecorder()
		e.h.ServeHTTP(rec, req)
		return rec
	}

	if rec := upload("alice", `{"Domains":"bob.example.test","ForwardScheme":"http","ForwardHost":"203.0.113.9","ForwardPort":80}`); rec.Code != http.StatusConflict {
		t.Errorf("importing a host onto a taken domain -> %d, want 409: %s", rec.Code, rec.Body.String())
	}

	rec := upload("alice", `{"Domains":"imp.example.test","ForwardScheme":"http","ForwardHost":"203.0.113.9","ForwardPort":80,
		"DNSProvider":"cloudflare","DNSZoneID":"zone-of-someone-else","DNSRecordID":"record-to-delete","CFDNSRecordID":"cf-rec","CFZoneID":"cf-zone","CertificateID":777}`)
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("a clean import -> %d: %s", rec.Code, rec.Body.String())
	}
	var prov, zone, rec1, cf, cert, skip = "", "", "", "", int64(0), 0
	if err := e.db.QueryRow(`SELECT COALESCE(dns_provider,''), COALESCE(dns_zone_id,''), COALESCE(dns_record_id,''), COALESCE(cf_dns_record_id,''), COALESCE(certificate_id,0), COALESCE(dns_skip_record,0) FROM proxy_hosts WHERE domains='imp.example.test'`).
		Scan(&prov, &zone, &rec1, &cf, &cert, &skip); err != nil {
		t.Fatal(err)
	}
	if prov != "" || zone != "" || rec1 != "" || cf != "" || cert != 0 {
		t.Errorf("uploaded DNS/certificate state survived the import: provider=%q zone=%q record=%q cf=%q cert=%d", prov, zone, rec1, cf, cert)
	}
}

// A certificate ID arrives as a plain number; the forms only offer visible
// certificates, but nothing checked that on the server.
func TestSecurityNonAdminCannotReferenceAnotherTenantsCertificate(t *testing.T) {
	e := newSecEnv(t)
	bobCert, err := models.CreateCertificate(e.db, 1, e.ids["bob"], &models.Certificate{
		Name: "bob-private", Domains: "bob.example.test", Source: models.CertSourcePEM, CertPEM: "c", KeyPEM: "k"})
	if err != nil {
		t.Fatal(err)
	}
	globalCert, err := models.CreateCertificate(e.db, 1, 0, &models.Certificate{
		Name: "wildcard", Domains: "*.example.test", Source: models.CertSourcePEM, CertPEM: "c", KeyPEM: "k"})
	if err != nil {
		t.Fatal(err)
	}
	host := func(cert int64, domain string) url.Values {
		return url.Values{"domains": {domain}, "forward_scheme": {"http"}, "forward_host": {"10.0.0.5"}, "forward_port": {"80"},
			"enabled": {"on"}, "certificate_id": {strconv.FormatInt(cert, 10)}}
	}
	if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", host(bobCert, "a1.example.test")); rec.Code == http.StatusSeeOther {
		t.Error("alice bound another tenant's private certificate to her host")
	}
	// A shared (global) certificate remains selectable.
	if rec := e.do(t, "alice", http.MethodPost, "/proxy-hosts", host(globalCert, "a2.example.test")); rec.Code != http.StatusSeeOther {
		t.Errorf("a global certificate was refused: %d %s", rec.Code, excerpt(rec.Body.String(), "not found"))
	}
}

// An unauthenticated failed login records the submitted email as the actor, and
// a visitor controls the path and User-Agent of every request, so exported cells
// can begin with a spreadsheet formula.
func TestSecurityCSVExportsNeutraliseFormulaInjection(t *testing.T) {
	cases := map[string]string{
		"=HYPERLINK(\"http://evil\",\"x\")": "'=HYPERLINK(\"http://evil\",\"x\")",
		"+1+1":                              "'+1+1",
		"-2":                                "'-2",
		"@SUM(A1)":                          "'@SUM(A1)",
		"\t=cmd":                            "'\t=cmd",
		"\r=cmd":                            "'\r=cmd",
		"plain text":                        "plain text",
		"admin@example.com":                 "admin@example.com",
		"":                                  "",
		"a=b":                               "a=b",
	}
	for in, want := range cases {
		if got := csvSafe(in); got != want {
			t.Errorf("csvSafe(%q) = %q, want %q", in, got, want)
		}
	}

	// End to end through the activity export.
	e := newSecEnv(t)
	_ = models.LogActivity(e.db, 0, `=HYPERLINK("http://evil","click")`, "login_fail", "ip:203.0.113.9", "invalid credentials", false)
	rec := e.do(t, "admin", http.MethodGet, "/activity/export.csv", nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("export -> %d", rec.Code)
	}
	if strings.Contains(rec.Body.String(), `,=HYPERLINK`) || !strings.Contains(rec.Body.String(), `'=HYPERLINK`) {
		t.Errorf("the exported actor still starts with a formula:\n%s", rec.Body.String())
	}
}

// Several flows carry a bearer secret in the query string (?token= for reset
// and invite links, ?created= for a new API token, ?t= for a pending 2FA step,
// the OIDC ?code=). The default request logger wrote the full URI, handing a
// working credential to anyone who can read the logs.
func TestSecurityRequestLogNeverContainsTheQueryString(t *testing.T) {
	e := newSecEnv(t)
	var buf bytes.Buffer
	prev := log.Writer()
	log.SetOutput(&buf)
	defer log.SetOutput(prev)

	for _, uri := range []string{
		"/reset-password?token=RESETSECRET123",
		"/api-tokens?created=cadu_APITOKENSECRET456",
		"/login/totp?t=PENDINGSECRET789",
		"/auth/oidc/callback?code=OIDCCODESECRET&state=STATESECRET",
	} {
		e.h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, uri, nil))
	}
	out := buf.String()
	for _, secret := range []string{"RESETSECRET123", "APITOKENSECRET456", "PENDINGSECRET789", "OIDCCODESECRET", "STATESECRET", "token=", "?code="} {
		if strings.Contains(out, secret) {
			t.Errorf("the request log contains %q:\n%s", secret, out)
		}
	}
	if !strings.Contains(out, "/reset-password") || !strings.Contains(out, "/login/totp") {
		t.Errorf("the request log should still record the path:\n%s", out)
	}
	// A path with control characters cannot forge a log line.
	buf.Reset()
	req := httptest.NewRequest(http.MethodGet, "/x", nil)
	req.URL.Path = "/x\nFAKE LOG LINE - 200 0B"
	e.h.ServeHTTP(httptest.NewRecorder(), req)
	if strings.Count(buf.String(), "\n") != 1 {
		t.Errorf("a newline in the path split the log entry:\n%q", buf.String())
	}
}

func TestLogSafeURLHidesWebhookSecrets(t *testing.T) {
	got := logSafeURL("https://hooks.slack.com/services/T000/B000/SECRETSECRET")
	if strings.Contains(got, "SECRETSECRET") || !strings.Contains(got, "hooks.slack.com") {
		t.Errorf("logSafeURL leaked the secret path: %q", got)
	}
	_, err := http.Get("http://127.0.0.1:1/services/SECRETSECRET")
	if err == nil {
		t.Skip("unexpectedly reachable")
	}
	if strings.Contains(unwrapURLError(err).Error(), "SECRETSECRET") {
		t.Errorf("unwrapURLError still carries the URL: %v", unwrapURLError(err))
	}
}

// --- cross-tenant visibility ----------------------------------------------

// Live traffic, the activity log, the dashboard's "recently edited" widget and
// the server API showed every tenant's data to every role.
func TestSecurityLowPrivilegeRolesOnlySeeTheirOwnTrafficAndActivity(t *testing.T) {
	e := newSecEnv(t)
	bobHost, err := models.CreateProxyHost(e.db, 1, e.ids["bob"], &models.ProxyHost{
		Domains: "bob.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.5", ForwardPort: 8080, Enabled: true})
	_ = bobHost
	if err != nil {
		t.Fatal(err)
	}
	if _, err := models.CreateProxyHost(e.db, 1, e.ids["alice"], &models.ProxyHost{
		Domains: "alice.example.test", ForwardScheme: "http", ForwardHost: "10.0.0.6", ForwardPort: 8080, Enabled: true}); err != nil {
		t.Fatal(err)
	}
	insert := func(host, ip string) {
		_, err := e.db.Exec(`INSERT INTO access_events (ts, server_id, server_name, host, path, method, status, client_ip, user_agent, duration_ms, bytes_out)
			VALUES (strftime('%s','now'), 1, 'primary', ?, '/p', 'GET', 200, ?, 'ua', 1, 1)`, host, ip)
		if err != nil {
			t.Fatal(err)
		}
	}
	insert("bob.example.test", "198.51.100.77")
	insert("alice.example.test", "198.51.100.88")
	_ = models.LogActivity(e.db, 1, "bob@t", "proxy_update", "proxy:1", "bob.example.test", true)
	_ = models.LogActivity(e.db, 1, "alice@t", "proxy_update", "proxy:2", "alice.example.test", true)
	_ = models.LogActivity(e.db, 0, "admin@t", "login_success", "ip:192.0.2.1", "ua", true)

	body := func(who, path string) string { return e.do(t, who, http.MethodGet, path, nil).Body.String() }

	// Live traffic: alice sees her host's traffic, never bob's.
	live := body("alice", "/live-traffic")
	if strings.Contains(live, "198.51.100.77") || strings.Contains(live, "bob.example.test") {
		t.Error("alice's live traffic shows bob's host")
	}
	if !strings.Contains(live, "198.51.100.88") {
		t.Error("alice's live traffic is missing her own host's request")
	}
	if v := body("viewer", "/live-traffic"); strings.Contains(v, "198.51.100.77") || strings.Contains(v, "198.51.100.88") {
		t.Error("a viewer with no hosts sees traffic")
	}
	if a := body("admin", "/live-traffic"); !strings.Contains(a, "198.51.100.77") || !strings.Contains(a, "198.51.100.88") {
		t.Error("the admin must still see all traffic")
	}

	// Activity log and its CSV export.
	for _, path := range []string{"/activity", "/activity/export.csv"} {
		got := body("alice", path)
		if strings.Contains(got, "bob.example.test") || strings.Contains(got, "192.0.2.1") {
			t.Errorf("alice's %s shows other accounts' activity", path)
		}
		if !strings.Contains(got, "alice.example.test") {
			t.Errorf("alice's %s is missing her own activity", path)
		}
		if adm := body("admin", path); !strings.Contains(adm, "bob.example.test") || !strings.Contains(adm, "192.0.2.1") {
			t.Errorf("the admin's %s must show everything", path)
		}
	}

	// The server API no longer hands every node's admin URL to every role.
	if got := body("alice", "/api/v1/servers"); strings.Contains(got, "admin_url") || strings.Contains(got, "127.0.0.1") {
		t.Errorf("a non-admin received a node's admin URL: %s", got)
	}
	if got := body("admin", "/api/v1/servers"); !strings.Contains(got, "admin_url") {
		t.Error("the admin must still see admin URLs")
	}
}

// A host name is user input, and % and _ are LIKE wildcards: a wildcard host of
// "*.%" matched every host and exposed other tenants' traffic.
func TestSecurityHostWildcardMatchingEscapesLikeMetacharacters(t *testing.T) {
	e := newSecEnv(t)
	for _, h := range []string{"a.example.test", "b.other.test"} {
		_, err := e.db.Exec(`INSERT INTO access_events (ts, server_id, server_name, host, path, method, status, client_ip, user_agent, duration_ms, bytes_out)
			VALUES (strftime('%s','now'), 1, 'p', ?, '/', 'GET', 200, '1.1.1.1', 'ua', 1, 1)`, h)
		if err != nil {
			t.Fatal(err)
		}
	}
	count := func(host string) int {
		t.Helper()
		totals, err := models.AccessTotalsSince(e.db, time.Now().Add(-time.Hour), host, 1)
		if err != nil {
			t.Fatal(err)
		}
		return totals.Views
	}
	if n := count("*.example.test"); n != 1 {
		t.Errorf("*.example.test matched %d host(s), want 1", n)
	}
	for _, evil := range []string{"*.%", "*._", "*.%.test", "*.ex_mple.test"} {
		if n := count(evil); n != 0 {
			t.Errorf("%q matched %d host(s) — a LIKE metacharacter was treated as a wildcard", evil, n)
		}
	}
}

// A stolen session used to be enough to remove an account's second factor.
func TestSecurityChangingTwoFactorNeedsTheCurrentPassword(t *testing.T) {
	e := newSecEnv(t)
	if err := models.SetUserTOTP(e.db, e.ids["alice"], "JBSWY3DPEHPK3PXP", true); err != nil {
		t.Fatal(err)
	}
	enabled := func() bool {
		u, _ := models.GetUserByID(e.db, e.ids["alice"])
		return u != nil && u.TOTPEnabled
	}
	for label, form := range map[string]url.Values{
		"no password":    {"action": {"disable"}},
		"wrong password": {"action": {"disable"}, "current_password": {"nope"}},
	} {
		rec := e.do(t, "alice", http.MethodPost, "/totp/setup", form)
		if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "error=password") {
			t.Errorf("%s: %d %q, want a redirect with error=password", label, rec.Code, rec.Header().Get("Location"))
		}
		if !enabled() {
			t.Fatalf("%s: 2FA was disabled", label)
		}
	}
	// Backup codes cannot be regenerated without it either.
	before, _ := models.GetUserByID(e.db, e.ids["alice"])
	rec := e.do(t, "alice", http.MethodPost, "/totp/regenerate-backup-codes", url.Values{})
	after, _ := models.GetUserByID(e.db, e.ids["alice"])
	if !strings.Contains(rec.Header().Get("Location"), "error=password") || before.BackupCodes != after.BackupCodes {
		t.Errorf("backup codes were regenerated without the password: %d %q", rec.Code, rec.Header().Get("Location"))
	}
	// With the password it works.
	rec = e.do(t, "alice", http.MethodPost, "/totp/setup", url.Values{"action": {"disable"}, "current_password": {"password-123"}})
	if rec.Code != http.StatusSeeOther || enabled() {
		t.Errorf("disabling with the right password: %d, still enabled=%v", rec.Code, enabled())
	}
}