package api import ( "net/http" "strings" "testing" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // corsHarness starts a harness with the given cors_origins list. The origins // are read once, when Handler() builds the wrapper, so a config edit afterwards // does not move them — which is the same freezing the settings route has and is // pinned by TestCORSOriginsAreReadAtHandlerBuild. func corsHarness(t *testing.T, origins ...string) *harness { t.Helper() return newHarness(t, func(c *config.Config) { c.API.CORSOrigins = origins }) } // TestCORSAllowedOrigin checks a configured origin is echoed back with the // header set a credentialed fetch needs, and that Vary: Origin is present. Vary // is the part that matters operationally: without it a shared cache will serve // one origin's allow-origin header to another. func TestCORSAllowedOrigin(t *testing.T) { h := corsHarness(t, "https://console.example", "http://localhost:5173") cases := []struct { name string origin string }{ {"https origin", "https://console.example"}, {"plain http origin", "http://localhost:5173"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: "+tc.origin) if res.status != http.StatusOK { t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) } if got := res.header.Get("Access-Control-Allow-Origin"); got != tc.origin { t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, tc.origin) } if got := res.header.Get("Vary"); got != "Origin" { t.Errorf("Vary = %q, want \"Origin\"", got) } if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" { t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) } if got := res.header.Get("Access-Control-Allow-Methods"); got == "" { t.Error("no Access-Control-Allow-Methods advertised") } if got := res.header.Get("Access-Control-Allow-Headers"); got == "" { t.Error("no Access-Control-Allow-Headers advertised") } }) } // The allowed methods/header lists must actually name what the console // sends, or a preflight for a PUT fails and the settings editor is // unusable from a browser. res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example") if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") || !strings.Contains(got, "DELETE") { t.Errorf("Access-Control-Allow-Methods = %q, want PUT and DELETE in it", got) } if got := res.header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-API-Key") || !strings.Contains(got, "Authorization") { t.Errorf("Access-Control-Allow-Headers = %q, want X-API-Key and Authorization in it", got) } } // TestCORSDisallowedOrigin checks an origin that is not configured gets no CORS // headers at all. The request still runs — CORS is enforced by the browser, not // by refusing the request — but the browser must not be told the origin is // allowed. func TestCORSDisallowedOrigin(t *testing.T) { h := corsHarness(t, "https://console.example") res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://evil.example") if res.status != http.StatusOK { t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) } for _, hdr := range []string{ "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", } { if got := res.header.Get(hdr); got != "" { t.Errorf("%s = %q for a disallowed origin, want it absent", hdr, got) } } // Vary is only added for an allowed origin, which is a caching footgun // this suite pins: a shared cache that stored the unvaried response could // hand the console's allow-origin to the other origin. if got := res.header.Get("Vary"); got != "" { t.Errorf("Vary = %q for a disallowed origin, want it absent", got) } } // TestCORSWildcardEchoesTheOrigin checks "*" is honoured and, as the code // comment says, echoed per request rather than answered as "*" — a literal // wildcard is not accepted by a credentialed fetch. func TestCORSWildcardEchoesTheOrigin(t *testing.T) { h := corsHarness(t, "*") for _, origin := range []string{"https://a.example", "https://b.example", "http://localhost:5173"} { res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: "+origin) if got := res.header.Get("Access-Control-Allow-Origin"); got != origin { t.Errorf("Access-Control-Allow-Origin for %s = %q, want the origin echoed", origin, got) } } // The wildcard is the risky setting, so pin that credentials are still // advertised for it: the code does NOT distinguish wildcard from an exact // origin, which means "*" plus credentials is only safe because the // wildcard is discouraged in the config comment. If this ever changes, the // browser semantics change with it, so it is pinned rather than assumed. res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://a.example") if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" { t.Errorf("wildcard: Access-Control-Allow-Credentials = %q, want \"true\" (pinned current behaviour)", got) } } // TestCORSPreflight checks OPTIONS is answered by the CORS wrapper with 204 and // the method/header list, before authentication and before the mux — a // preflight carries no API key, so a route that answered it would answer 401. func TestCORSPreflight(t *testing.T) { h := corsHarness(t, "https://console.example") cases := []struct { name string path string origin string wantACA bool }{ {"registered route", "/api/whoami", "https://console.example", true}, // The preflight is answered for any origin, allowed or not: the // browser compares Access-Control-Allow-Origin itself, and a 403 // answer to a preflight is not a thing the browser can interpret. {"disallowed origin", "/api/whoami", "https://evil.example", false}, // Not a registered route at all — the wrapper runs before the mux. {"unknown route", "/api/nope", "https://console.example", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { res := h.do(http.MethodOptions, tc.path, "", "Origin: "+tc.origin, "Access-Control-Request-Method: PUT", "Access-Control-Request-Headers: x-api-key") if res.status != http.StatusNoContent { t.Fatalf("OPTIONS %s = %d, want 204: %s", tc.path, res.status, res.body) } if len(res.body) != 0 { t.Errorf("preflight returned a body: %q", res.body) } acao := res.header.Get("Access-Control-Allow-Origin") if tc.wantACA && acao != tc.origin { t.Errorf("Access-Control-Allow-Origin = %q, want %q", acao, tc.origin) } if !tc.wantACA && acao != "" { t.Errorf("Access-Control-Allow-Origin = %q for a disallowed origin, want it absent", acao) } if tc.wantACA { if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") { t.Errorf("Access-Control-Allow-Methods = %q, want PUT in it", got) } // HTTP header names are case-insensitive and the server // advertises a fixed list, so compare case-insensitively. if got := strings.ToLower(res.header.Get("Access-Control-Allow-Headers")); !strings.Contains(got, "x-api-key") { t.Errorf("Access-Control-Allow-Headers = %q, want x-api-key in it", got) } } }) } } // TestCORSDisabled checks a config without cors_origins is not wrapped at all: // no headers, and OPTIONS falls through to the mux, which answers a method it // does not serve rather than pretending a preflight succeeded. func TestCORSDisabled(t *testing.T) { h := newHarness(t, nil) // testConfig leaves CORSOrigins nil res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example") if res.status != http.StatusOK { t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) } if got := res.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Access-Control-Allow-Origin = %q with no CORS configured, want it absent", got) } res = h.do(http.MethodOptions, "/api/whoami", "", "Origin: https://console.example") if res.status != http.StatusMethodNotAllowed { t.Errorf("OPTIONS with no CORS configured = %d, want 405 from the mux: %s", res.status, res.body) } } // TestCORSOriginsAreReadAtHandlerBuild pins the same freezing the settings // route has for the rest of the auth config: the allow-list is captured when // Handler() is called, so adding an origin through the settings endpoint // answers ok and changes nothing until a restart. Written down so that fixing // the freezing later starts from a test that says what it is. func TestCORSOriginsAreReadAtHandlerBuild(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://old.example"} }) body := settingsBody(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://new.example"} }) if res := h.do(http.MethodPut, "/api/settings", body, "X-API-Key: rw-key"); res.status != http.StatusOK { t.Fatalf("settings put = %d: %s", res.status, res.body) } // The store carries the new list... if got := h.store.Get().API.CORSOrigins; len(got) != 1 || got[0] != "https://new.example" { t.Fatalf("store cors_origins = %v, want the written list", got) } // ...but the running server still answers for the origin it was built with. res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://new.example") if got := res.header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("the written origin is already allowed (%q): the allow-list is not frozen as this test pins it", got) } if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://old.example"); res.header.Get("Access-Control-Allow-Origin") == "" { t.Error("the origin the server was built with is no longer allowed") } }