Files
dsh-web/internal/api/auth_security_test.go

272 lines
7.8 KiB
Go

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://[email protected]"} {
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)
}
}