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) } } } 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) } }