Files
fancy-dns/internal/api/auth_test.go
T
developer 48d37db15b
build / build-push (push) Failing after 19s
Add the first internal/api test suite
internal/api is the admin REST + SSE surface and had no test file at all,
so nothing pinned the auth modes, the X-Forwarded-For trust boundary, the
rate limiter or the fact that a read route must not mutate anything. This
adds a local harness (a Server over fake hooks on an httptest listener,
with a real config file on disk) and three test files.

What it pins:

* Routing. A table over every route in Server.routes: each one answers 200
  and reaches the hook it is supposed to, so a new route without a case
  here is a route nothing tests. Plus method-not-allowed / 404 / outside
  the base path, a base path other than /api, and a read-route sweep that
  asserts no mutating hook was reached, the config generation did not move
  and the file on disk is byte-identical afterwards. A panicking component
  becomes a 500 and the server keeps serving, because the admin API is the
  only way to fix a broken instance.
* Auth. All three modes. apikey: the right key always works, and a prefix,
  a suffix, a case-folded key, a wrong basic password, an unknown bearer
  token, an unsupported scheme and a malformed Authorization header never
  do; curl -u any:KEY is accepted because that is what clients send.
  basic: bad username and bad password refused, a successful login issues a
  bearer token, the token works for the whole API, basic auth also works
  per request, an API key is not a credential in this mode, logout revokes
  the token, and a token past its session_ttl stops working. none: nothing
  is required and the caller runs as a writable loopback principal. trust
  _loopback: a local client with no credentials is let through, and a
  forwarded non-loopback client is not. Every mutating route refuses a
  read-only key with 403 without reaching its hook or touching the file
  (and the same request with a read-write key succeeds, so the 403 is about
  the key and not the payload), and allow_config_write: false refuses the
  settings editor with a valid key while the other mutations still work.
  The hashing is pinned on the state, not on timing: the authenticator's
  maps are keyed on the SHA-256 of the credential and hold no plaintext key
  or password, so a copied server struct cannot leak one.
* Product code is untouched; the suite is `package api` with its own setup.
2026-09-27 15:54:50 +00:00

415 lines
17 KiB
Go

package api
import (
"encoding/base64"
"net/http"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func basicHeader(user, pass string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}
func modeAPIKey(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
func modeBasic(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "basic",
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
// TestAuthModeAPIKey covers every way a request can present a key in apikey
// mode, and the invariant that matters: the right key always works and every
// other credential never does.
func TestAuthModeAPIKey(t *testing.T) {
cases := []struct {
name string
header string
want int
wantName string
wantWrite bool
wantAuthMd string
}{
{name: "no credentials", header: "", want: http.StatusUnauthorized},
{name: "correct key", header: "X-API-Key: rw-key", want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "read-only key", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
{name: "wrong key", header: "X-API-Key: nope", want: http.StatusUnauthorized},
{name: "key prefix", header: "X-API-Key: rw-ke", want: http.StatusUnauthorized},
{name: "key with suffix", header: "X-API-Key: rw-keyx", want: http.StatusUnauthorized},
{name: "key case folded", header: "X-API-Key: RW-KEY", want: http.StatusUnauthorized},
{name: "read-only key keeps its label", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
// curl -u any:KEY is a common client convention and is supported.
{name: "key in the basic password field", header: "Authorization: " + basicHeader("any", "rw-key"), want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "wrong basic password", header: "Authorization: " + basicHeader("any", "nope"), want: http.StatusUnauthorized},
{name: "unsupported scheme", header: "Authorization: Digest whatever", want: http.StatusUnauthorized},
{name: "malformed authorization", header: "Authorization: Bearer", want: http.StatusUnauthorized},
{name: "unknown bearer token", header: "Authorization: Bearer deadbeef", want: http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
res := h.do(http.MethodGet, "/api/whoami", "", tc.header)
if res.status != tc.want {
t.Fatalf("GET /api/whoami with %q = %d, want %d: %s", tc.header, res.status, tc.want, res.body)
}
if tc.want != http.StatusOK {
return
}
who := res.json(t)
if who["name"] != tc.wantName {
t.Errorf("name = %v, want %v", who["name"], tc.wantName)
}
if who["can_write"] != tc.wantWrite {
t.Errorf("can_write = %v, want %v", who["can_write"], tc.wantWrite)
}
if who["auth_mode"] != tc.wantAuthMd {
t.Errorf("auth_mode = %v, want %v", who["auth_mode"], tc.wantAuthMd)
}
})
}
}
// TestAuthModeBasic covers the password login, the bearer session it issues and
// logout, plus the per-request basic form.
func TestAuthModeBasic(t *testing.T) {
h := newHarness(t, modeBasic)
// Wrong password is refused and does not issue a token.
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"wrong"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad password login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"nobody","password":"s3cret"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad username login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
body := res.json(t)
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("login returned no token: %s", res.body)
}
if body["mode"] != "basic" || body["read_only"] != false {
t.Errorf("login payload mode/read_only = %v/%v", body["mode"], body["read_only"])
}
// The token is a bearer credential for the whole API.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Errorf("bearer whoami = %d: %s", res.status, res.body)
}
// Basic auth also works per request, without a session.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: "+basicHeader("admin", "s3cret")); res.status != http.StatusOK {
t.Errorf("basic whoami = %d: %s", res.status, res.body)
}
// An API key is not a credential in basic mode.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusUnauthorized {
t.Errorf("api key in basic mode = %d, want 401", res.status)
}
// Logout revokes the session; the token must stop working.
if res := h.do(http.MethodPost, "/api/auth/logout", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("logout = %d: %s", res.status, res.body)
}
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusUnauthorized {
t.Errorf("revoked token = %d, want 401", res.status)
}
}
// TestSessionExpiry checks a session stops being accepted once it is past its
// TTL, so a stolen token is not valid forever.
func TestSessionExpiry(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeBasic(c)
c.API.Auth.SessionTTL = config.Duration(10 * time.Millisecond)
})
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
token, _ := res.json(t)["token"].(string)
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("fresh session = %d: %s", res.status, res.body)
}
waitFor(t, time.Second, "the session to expire", func() bool {
return h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token).status == http.StatusUnauthorized
})
}
// TestAuthModeNone pins mode "none": nothing is required, and the caller runs
// as a local principal that may write.
func TestAuthModeNone(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("unauthenticated whoami in mode none = %d: %s", res.status, res.body)
}
who := res.json(t)
if who["mode"] != "loopback" || who["can_write"] != true {
t.Errorf("whoami = %+v, want a writable loopback principal", who)
}
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: totally-wrong"); res.status != http.StatusOK {
t.Errorf("a rejected key in mode none = %d, want 200: the mode wins", res.status)
}
// Basic auth is not a configured scheme in this mode.
if res := h.do(http.MethodPost, "/api/auth/login", `{"username":"a","password":"b"}`); res.status != http.StatusUnauthorized {
t.Errorf("login in mode none = %d, want 401", res.status)
}
}
// TestLoginRequestValidation covers the login endpoint's own input checks.
func TestLoginRequestValidation(t *testing.T) {
h := newHarness(t, modeAPIKey)
cases := []struct {
name string
body string
want int
}{
{"empty body", ``, http.StatusBadRequest},
{"no credentials", `{}`, http.StatusBadRequest},
{"malformed json", `{`, http.StatusBadRequest},
{"unknown field", `{"api_key":"rw-key","nope":1}`, http.StatusBadRequest},
{"wrong key", `{"api_key":"nope"}`, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := h.do(http.MethodPost, "/api/auth/login", tc.body)
if res.status != tc.want {
t.Errorf("login %q = %d, want %d: %s", tc.body, res.status, tc.want, res.body)
}
})
}
}
// TestReadOnlyKeyCannotWrite walks every mutating route with a read-only key:
// each one must be refused and none of them may reach its hook.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
writes := []struct {
name string
method string
path string
body string
hook string
}{
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
{"logs clear", http.MethodPost, "/api/logs/clear", "", "ClearLog"},
{"domain add", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, "AddDomain"},
{"domain delete", http.MethodDelete, "/api/domains/a.example.com", "", "DeleteDomain"},
{"blocklist refresh", http.MethodPost, "/api/blocklists/refresh", "", "RefreshBlocklists"},
{"blocklist toggle", http.MethodPost, "/api/blocklists/toggle", `{"name":"a","enabled":true}`, "ToggleBlocklist"},
{"clients put", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "PutACL"},
{"upstreams put", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, "PutUpstream"},
{"settings put", http.MethodPut, "/api/settings", settingsBody(t, nil), ""},
{"settings reload", http.MethodPost, "/api/settings/reload", "", ""},
}
for _, tc := range writes {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
before := h.readConfig()
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: ro-key")
if res.status != http.StatusForbidden {
t.Fatalf("%s %s with a read-only key = %d, want 403: %s", tc.method, tc.path, res.status, res.body)
}
if got := res.json(t)["error"]; got != "this key is read-only" {
t.Errorf("error = %v, want \"this key is read-only\"", got)
}
if tc.hook != "" && h.fake.count(tc.hook) != 0 {
t.Errorf("refused request still reached hook %s", tc.hook)
}
if h.readConfig() != before {
t.Errorf("%s %s rewrote the config file", tc.method, tc.path)
}
// The same request with a read-write key is allowed, so the 403
// above is about the key and not about the payload.
if res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("%s %s with a read-write key = %d: %s", tc.method, tc.path, res.status, res.body)
}
})
}
}
// TestConfigWriteDisabled checks allow_config_write: false refuses the settings
// editor with a valid key, while the other mutations still work.
func TestConfigWriteDisabled(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.AllowConfigWrite = false
})
before := h.readConfig()
res := h.do(http.MethodPut, "/api/settings", settingsBody(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusForbidden {
t.Fatalf("settings put = %d, want 403: %s", res.status, res.body)
}
if got := res.json(t)["error"]; got != "config writes are disabled" {
t.Errorf("error = %v, want \"config writes are disabled\"", got)
}
if h.readConfig() != before {
t.Error("a refused settings write still rewrote the config file")
}
// The gate is specific to the config document.
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("cache flush with config writes disabled = %d, want 200", res.status)
}
}
// TestTrustLoopback checks trust_loopback lets a local client through with no
// credentials at all, and that a *wrong* credential is still refused: the
// loopback exemption is a fallback, not an override.
func TestTrustLoopback(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("loopback whoami without a key = %d, want 200: %s", res.status, res.body)
}
// A key that does not match is not accepted as a key: the loopback
// exemption is the fallback for a request with no usable credential, so a
// local client lands on the loopback principal rather than a named one.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusOK {
t.Fatalf("loopback with a wrong key = %d, want 200: %s", res.status, res.body)
} else if name := res.json(t)["name"]; name != "loopback" {
t.Errorf("a wrong key on loopback ran as %v, want the loopback principal", name)
}
// Without trust_loopback the same request is refused.
hNoTrust := newHarness(t, modeAPIKey)
if res := hNoTrust.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusUnauthorized {
t.Errorf("wrong key without trust_loopback = %d, want 401", res.status)
}
// A trusted proxy in front of a loopback client does not extend the
// exemption to the forwarded address, which is not loopback.
h2 := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
c.API.TrustedProxies = []string{"127.0.0.0/8"}
})
res = h2.do(http.MethodGet, "/api/whoami", "", "X-Forwarded-For: 198.51.100.7")
if res.status != http.StatusUnauthorized {
t.Errorf("forwarded non-loopback client = %d, want 401", res.status)
}
}
// TestAPIKeysAreNotStoredInPlaintext pins the hashing: the authenticator keys
// its maps on the SHA-256 of the credential, so the configured key never sits
// in the long-lived structure as itself.
func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
a := newAuthenticator(config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
})
if label, ok := a.readWrite[hashKey("rw-key")]; !ok || label != "rw-1" {
t.Errorf("readWrite is not keyed on the key hash: %v", a.readWrite)
}
if label, ok := a.readOnly[hashKey("ro-key")]; !ok || label != "ro-1" {
t.Errorf("readOnly is not keyed on the key hash: %v", a.readOnly)
}
if a.passwordHash == "s3cret" || a.passwordHash != hashKey("s3cret") {
t.Errorf("passwordHash = %q, want the sha256 of the password", a.passwordHash)
}
for _, stored := range append(mapKeys(a.readWrite), mapKeys(a.readOnly)...) {
if stored == "rw-key" || stored == "ro-key" || stored == "s3cret" {
t.Errorf("a credential is stored in plaintext: %q", stored)
}
if len(stored) != 64 {
t.Errorf("stored form %q is not a hex sha256", stored)
}
}
// constantTimeEqual is the comparison helper: equal only when equal.
if !constantTimeEqual("abc", "abc") {
t.Error("constantTimeEqual(abc, abc) = false")
}
if constantTimeEqual("abc", "abd") || constantTimeEqual("abc", "ab") || constantTimeEqual("", "x") {
t.Error("constantTimeEqual accepted a mismatch")
}
}
func mapKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestAPIModeReported checks AuthMode/whoami report the configured mode, which
// is what the console uses to decide whether to offer a login form.
func TestAPIModeReported(t *testing.T) {
cases := []struct {
mode string
want string
}{
{"apikey", "apikey"},
{"basic", "basic"},
{"none", "none"},
}
for _, tc := range cases {
t.Run(tc.mode, func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
if tc.mode == "basic" {
modeBasic(c)
} else if tc.mode == "none" {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
}
})
if got := h.srv.AuthMode(); got != tc.want {
t.Errorf("AuthMode() = %q, want %q", got, tc.want)
}
auth := "X-API-Key: rw-key"
if tc.mode == "basic" {
auth = "Authorization: " + basicHeader("admin", "s3cret")
} else if tc.mode == "none" {
auth = ""
}
res := h.do(http.MethodGet, "/api/whoami", "", auth)
if res.status != http.StatusOK {
t.Fatalf("whoami = %d: %s", res.status, res.body)
}
if got := res.json(t)["auth_mode"]; got != tc.want {
t.Errorf("whoami auth_mode = %v, want %q", got, tc.want)
}
})
}
}
// TestBasicAuthDisabledWithoutPassword checks basic mode with no configured
// password refuses rather than accepting an empty one.
func TestBasicAuthDisabledWithoutPassword(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "basic", Username: "admin", SessionTTL: config.Duration(time.Hour)}
})
if err := h.cfg.Validate(); err != nil && !strings.Contains(err.Error(), "username") {
t.Fatalf("unexpected validation error: %v", err)
}
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":""}`)
if res.status != http.StatusUnauthorized {
t.Errorf("login with no password configured = %d, want 401: %s", res.status, res.body)
}
}