internal/server/security_regression_test.go
Rama main · réplica de lectura del repositorio original.
// 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())
}
}