package api import ( "fmt" "io" "net" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" ) func TestConfiguredOriginRejectsInvalidOrigins(t *testing.T) { for _, raw := range []string{"https://", "https://:8443", "https://panel.test?", "https://panel.test:bad", "ftp://panel.test", "https://panel.test/path"} { if _, err := configuredOrigin(raw); err == nil { t.Errorf("accepted invalid origin %q", raw) } } for _, raw := range []string{"https://panel.test", "https://panel.test:8443", "http://127.0.0.1:8088"} { if got, err := configuredOrigin(raw); err != nil || got != raw { t.Errorf("origin %q => %q, %v", raw, got, err) } } } func TestLoginSourceNilMapAndInvalidForwardedChain(t *testing.T) { a := &API{} if !a.allowLoginSource("127.0.0.1") { t.Fatal("nil source map rejected") } a.trustedProxies = mustCIDRs(t, "10.0.0.0/8") r := httptest.NewRequest("POST", "http://panel.test/api/login", nil) r.RemoteAddr = "10.0.0.1:1234" r.Header.Set("X-Forwarded-For", "198.51.100.4,not-an-ip,10.0.0.2") if got := a.loginSource(r); got != "10.0.0.1" { t.Fatalf("invalid chain crossed trust boundary: %s", got) } } func mustCIDRs(t *testing.T, raw string) []*net.IPNet { t.Helper() proxies, err := configuredProxyCIDRs(raw, nil) if err != nil { t.Fatal(err) } return proxies } func TestSecurityConfigValidation(t *testing.T) { for _, raw := range []string{"https://panel.test:0", "https://panel.test:65536", "https://panel.test:", "https://panel.test#", "https://bad_host", "https://[bad]", "https://user@panel.test"} { if _, err := configuredOrigin(raw); err == nil { t.Errorf("accepted %q", raw) } } for raw, want := range map[string]string{"https://PANEL.EXAMPLE:443/": "https://panel.example", "http://PANEL.EXAMPLE:80": "http://panel.example", "https://[::1]:443": "https://[::1]"} { if got, err := configuredOrigin(raw); err != nil || got != want { t.Errorf("%q => %q, %v", raw, got, err) } } for _, raw := range []string{"bad", "10.0.0.1", "10.0.0.0/8,", ",", "10.0.0.0/33"} { if _, err := configuredProxyCIDRs(raw, nil); err == nil { t.Errorf("accepted CIDRs %q", raw) } } for _, key := range []string{"PANEL_PUBLIC_URL", "TRUSTED_PROXY_CIDRS"} { t.Run(key, func(t *testing.T) { t.Setenv("PANEL_PUBLIC_URL", "") t.Setenv("TRUSTED_PROXY_CIDRS", "") t.Setenv(key, "invalid") if ValidateAuthConfig() == nil { t.Fatal("configuration accepted") } a := New(nil, t.TempDir()) if a.ConfigError() == nil { t.Fatal("missing config error") } for _, path := range []string{"/api/login", "/api/me", "/api/refresh", "/dsh/example/"} { w := httptest.NewRecorder() a.Handler().ServeHTTP(w, httptest.NewRequest("POST", path, strings.NewReader("{}"))) if w.Code != 503 { t.Fatalf("%s: %d", path, w.Code) } } }) } } func TestSecurityTLSCookieJarLifecycle(t *testing.T) { a, _ := authFixture(t) a.origin = "" a.loginSlots = nil a.loginSources = nil srv := httptest.NewTLSServer(a.Handler()) defer srv.Close() client := srv.Client() client.Jar, _ = cookiejar.New(nil) u, _ := url.Parse(srv.URL + "/api/me") request := func(method, path, body string, status int) []*http.Cookie { t.Helper() r, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body)) r.Header.Set("Origin", srv.URL) r.Header.Set("X-CSRF-Token", cookieValue(client.Jar.Cookies(u), "dsh_csrf")) resp, err := client.Do(r) if err != nil { t.Fatal(err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) if resp.StatusCode != status { t.Fatalf("%s: %d %s", path, resp.StatusCode, data) } return resp.Cookies() } check := func(cookies []*http.Cookie, deleting bool) { t.Helper() if len(cookies) != 4 { t.Fatalf("cookies: %v", cookies) } for _, c := range cookies { path := "/" if c.Name == "dsh_refresh" { path = "/api" } if !c.Secure || c.HttpOnly != (c.Name != "dsh_csrf") || c.Path != path || c.SameSite != http.SameSiteLaxMode || (deleting && c.MaxAge != -1) { t.Fatalf("cookie: %+v", c) } } } check(request("POST", "/api/login", `{"username":"alice","password":"password-123"}`, 200), false) request("GET", "/api/me", "", 200) old := cookieValue(client.Jar.Cookies(u), "dsh_refresh") check(request("POST", "/api/refresh", "", 200), false) if old == cookieValue(client.Jar.Cookies(u), "dsh_refresh") { t.Fatal("refresh not rotated") } request("GET", "/api/me", "", 200) check(request("POST", "/api/logout", "", 200), true) if len(client.Jar.Cookies(u)) != 0 { t.Fatal("logout left cookies") } request("GET", "/api/me", "", 401) } func TestSecurityLoginBudgetsAndUntrustedHeaders(t *testing.T) { a, _ := authFixture(t) a.origin = "" request := func(peer, origin, body string) int { r := httptest.NewRequest("POST", "http://panel.test/api/login", strings.NewReader(body)) r.RemoteAddr = peer + ":1234" r.Header.Set("Origin", origin) r.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", len(a.loginSources))) r.Header.Set("X-Forwarded-Proto", "https") r.Header.Set("X-Forwarded-Host", "evil.test") w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) return w.Code } for i := 0; i < 35; i++ { if got := request("192.0.2.1", "https://evil.test", "{}"); got != 403 { t.Fatal(got) } for _, body := range []string{"{", "{} garbage", "{} {}", strings.Repeat(" ", 4097)} { if got := request("192.0.2.1", "http://panel.test", body); got != 400 { t.Fatal(got) } } } if len(a.loginSources) != 0 { t.Fatal("invalid requests consumed budget") } for i := 0; i < 30; i++ { if got := request("192.0.2.1", "http://panel.test", "{}"); got != 401 { t.Fatal(i, got) } } if got := request("192.0.2.1", "http://panel.test", "{}"); got != 429 { t.Fatal(got) } if got := request("192.0.2.2", "http://panel.test", "{}"); got != 401 { t.Fatal("other source blocked", got) } if len(a.loginSources) != 2 { t.Fatal("forwarded headers changed source") } r := httptest.NewRequest("POST", "http://panel.test", nil) r.Header.Set("X-Forwarded-Proto", "https") if a.secureCookies(r) { t.Fatal("untrusted proto changed cookie policy") } a.origin = "https://panel.test" if !a.secureCookies(r) { t.Fatal("configured HTTPS not secure") } } func TestSecurityLoginSlotOverloadRecovery(t *testing.T) { a, req := authFixture(t) for i := 0; i < 8; i++ { if !a.acquireLoginSlot() { t.Fatal("slot unavailable") } } if w := req("POST", "/api/login", "{}", "", nil); w.Code != 503 { t.Fatal("overload not rejected", w.Code) } <-a.loginSlots if w := req("POST", "/api/login", "{}", "", nil); w.Code != 401 { t.Fatal("released slot not usable", w.Code) } if len(a.loginSlots) != 7 { t.Fatal("request leaked slot") } } func TestSecurityConcurrentSlotsAndBoundedBuckets(t *testing.T) { a := &API{} var wg sync.WaitGroup results := make(chan bool, 64) for i := 0; i < 64; i++ { wg.Add(1) go func() { defer wg.Done(); results <- a.acquireLoginSlot() }() } wg.Wait() close(results) count := 0 for ok := range results { if ok { count++ } } if count != 8 { t.Fatalf("slots: %d", count) } <-a.loginSlots if !a.acquireLoginSlot() { t.Fatal("released slot unavailable") } for i := 0; i < 1024; i++ { if !a.allowLoginSource(fmt.Sprint(i)) { t.Fatal(i) } } if a.allowLoginSource("overflow") || len(a.loginSources) != 1024 { t.Fatal("unbounded buckets") } a.loginSources["0"] = loginSource{started: time.Now().Add(-time.Minute), count: 30} if !a.allowLoginSource("overflow") || len(a.loginSources) != 1024 { t.Fatal("expired bucket not reclaimed") } b := &API{} accepted := make(chan bool, 64) for i := 0; i < 64; i++ { wg.Add(1) go func() { defer wg.Done(); accepted <- b.allowLoginSource("same") }() } wg.Wait() close(accepted) count = 0 for ok := range accepted { if ok { count++ } } if count != 30 { t.Fatalf("concurrent budget: %d", count) } }