272 lines
7.8 KiB
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)
|
|
}
|
|
}
|