package dashboard import ( "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" ) const ( testUser = "admin" testPass = "correct horse battery staple" testSecret = "test-session-secret" ) func newAuthTestServer(t *testing.T, caURL string, mutate func(*Config)) (*Server, *httptest.Server) { t.Helper() cfg := Config{ ControlAPIBaseURL: caURL, ControlAPITimeout: 5 * time.Second, LastCompletedCount: 20, OverviewPollIntervalS: 5, ControlAPIToken: "ca-admin-token", Username: testUser, Password: testPass, SessionSecret: testSecret, SessionTTL: time.Hour, } if mutate != nil { mutate(&cfg) } log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) srv, err := New(cfg, log) if err != nil { t.Fatalf("new dashboard server: %v", err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return srv, ts } // noFollow is a client that returns redirects as-is. func noFollow(ts *httptest.Server) *http.Client { c := *ts.Client() c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } return &c } type reqOpts struct { method string path string form url.Values cookie string headers map[string]string } func doReq(t *testing.T, ts *httptest.Server, o reqOpts) (*http.Response, string) { t.Helper() if o.method == "" { o.method = http.MethodGet } var body io.Reader if o.form != nil { body = strings.NewReader(o.form.Encode()) } req, err := http.NewRequest(o.method, ts.URL+o.path, body) if err != nil { t.Fatalf("new request: %v", err) } if o.form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if o.cookie != "" { req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: o.cookie}) } for k, v := range o.headers { req.Header.Set(k, v) } resp, err := noFollow(ts).Do(req) if err != nil { t.Fatalf("%s %s: %v", o.method, o.path, err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, string(b) } func sessionCookie(resp *http.Response) *http.Cookie { for _, c := range resp.Cookies() { if c.Name == sessionCookieName { return c } } return nil } func loginForm(user, pass string) url.Values { return url.Values{"username": {user}, "password": {pass}} } // login performs a successful login and returns the session cookie value. func login(t *testing.T, ts *httptest.Server) string { t.Helper() resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass), headers: map[string]string{"Origin": ts.URL}}) c := sessionCookie(resp) if resp.StatusCode != http.StatusSeeOther || c == nil { t.Fatalf("login: status=%d cookie=%v, want 303 + session cookie", resp.StatusCode, c) } return c.Value } func TestUnauthenticatedRedirectsToLogin(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) resp, _ := doReq(t, ts, reqOpts{path: "/ips"}) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status=%d, want 303", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/login?next=%2Fips" { t.Fatalf("Location=%q, want /login?next=%%2Fips", loc) } } func TestUnauthenticatedHTMXGets401WithHXRedirect(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) resp, _ := doReq(t, ts, reqOpts{path: "/overview/fragment", headers: map[string]string{"HX-Request": "true"}}) if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("HX-Redirect") != "/login" { t.Fatalf("status=%d HX-Redirect=%q, want 401 + /login", resp.StatusCode, resp.Header.Get("HX-Redirect")) } } func TestLoginSuccessSetsCookieAndGrantsAccess(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"/ips"}}, headers: map[string]string{"Origin": ts.URL}}) if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ips" { t.Fatalf("status=%d Location=%q, want 303 /ips", resp.StatusCode, resp.Header.Get("Location")) } c := sessionCookie(resp) if c == nil { t.Fatal("no session cookie") } if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" || c.MaxAge <= 0 { t.Fatalf("cookie flags: httponly=%v samesite=%v path=%q maxage=%d", c.HttpOnly, c.SameSite, c.Path, c.MaxAge) } if c.Secure { t.Fatal("cookie must not be Secure over plain HTTP") } resp, body := doReq(t, ts, reqOpts{path: "/overview", cookie: c.Value}) if resp.StatusCode != http.StatusOK { t.Fatalf("authenticated GET /overview: status=%d", resp.StatusCode) } if !strings.Contains(body, "Выйти") || !strings.Contains(body, testUser) { t.Fatal("sidebar must show the user and the logout button when auth is enabled") } } func TestCookieSecureBehindHTTPSProxy(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass), headers: map[string]string{"Origin": ts.URL, "X-Forwarded-Proto": "https"}}) if c := sessionCookie(resp); c == nil || !c.Secure { t.Fatalf("cookie=%v, want Secure with X-Forwarded-Proto=https", c) } } func TestLoginWrongPasswordShowsErrorWithoutCookie(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) for _, f := range []url.Values{loginForm(testUser, "nope"), loginForm("nobody", testPass)} { resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: f, headers: map[string]string{"Origin": ts.URL}}) if resp.StatusCode != http.StatusOK || sessionCookie(resp) != nil { t.Fatalf("status=%d cookie=%v, want 200 and no cookie", resp.StatusCode, sessionCookie(resp)) } if !strings.Contains(body, "Неверный логин или пароль") { t.Fatal("login page must show the error") } } } func TestTamperedAndExpiredCookiesRejected(t *testing.T) { _, caURL := newFakeControlAPI(t) srv, ts := newAuthTestServer(t, caURL, nil) good := login(t, ts) payload, sig, _ := strings.Cut(good, ".") for name, v := range map[string]string{ "tampered payload": "AAAA" + payload[4:] + "." + sig, "tampered signature": payload + "." + sig[:len(sig)-2] + "AA", "no signature": payload, "garbage": "x.y", "signed with new key": otherKeyCookie(t), } { resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: v}) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("%s: status=%d, want 303 to /login", name, resp.StatusCode) } } // Expiry: advance the server's clock beyond the TTL. srv.auth.now = func() time.Time { return time.Now().Add(2 * time.Hour) } resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("expired cookie: status=%d, want 303", resp.StatusCode) } } func otherKeyCookie(t *testing.T) string { t.Helper() a := newAuthState(Config{Username: "u", Password: "p", SessionSecret: "another-secret"}, slog.New(slog.NewTextHandler(io.Discard, nil))) v, _ := a.issue(testUser) return v } func TestLogoutClearsCookie(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) good := login(t, ts) resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/logout", cookie: good, headers: map[string]string{"Origin": ts.URL}}) c := sessionCookie(resp) if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" || c == nil || c.MaxAge >= 0 || c.Value != "" { t.Fatalf("logout: status=%d loc=%q cookie=%+v", resp.StatusCode, resp.Header.Get("Location"), c) } } func TestCSRFOriginCheck(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) good := login(t, ts) cases := map[string]map[string]string{ "foreign origin": {"Origin": "http://evil.example"}, "null origin": {"Origin": "null"}, "no origin/referer": {}, "foreign referer": {"Referer": "http://evil.example/page"}, } for name, h := range cases { resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h}) if resp.StatusCode != http.StatusForbidden { t.Fatalf("%s: status=%d, want 403", name, resp.StatusCode) } } // Login itself is covered too. resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass), headers: map[string]string{"Origin": "http://evil.example"}}) if resp.StatusCode != http.StatusForbidden || sessionCookie(resp) != nil { t.Fatalf("cross-origin login: status=%d, want 403 and no cookie", resp.StatusCode) } // Same-origin Origin, and Referer fallback, pass. for name, h := range map[string]map[string]string{ "origin": {"Origin": ts.URL}, "referer": {"Referer": ts.URL + "/ips"}, } { resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h}) if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusSeeOther { t.Fatalf("same-origin %s: status=%d, want request to be served", name, resp.StatusCode) } } } func TestLoginThrottle(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) for i := 0; i < maxLoginFailures; i++ { resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, "bad"), headers: map[string]string{"Origin": ts.URL}}) if resp.StatusCode != http.StatusOK { t.Fatalf("attempt %d: status=%d, want 200", i+1, resp.StatusCode) } } // Now locked out, even with the right password. resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass), headers: map[string]string{"Origin": ts.URL}}) if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" || sessionCookie(resp) != nil { t.Fatalf("status=%d Retry-After=%q, want 429 with Retry-After and no cookie", resp.StatusCode, resp.Header.Get("Retry-After")) } } func TestLoginThrottleClearedBySuccessAndExpires(t *testing.T) { th := newLoginThrottle() now := time.Now() for i := 0; i < maxLoginFailures-1; i++ { th.fail("1.2.3.4", now) } if b, _ := th.blocked("1.2.3.4", now); b { t.Fatal("blocked before reaching the limit") } th.fail("1.2.3.4", now) if b, _ := th.blocked("1.2.3.4", now); !b { t.Fatal("not blocked at the limit") } if b, _ := th.blocked("1.2.3.4", now.Add(loginFailureWindow+time.Second)); b { t.Fatal("still blocked after the window") } if len(th.failures) != 0 { t.Fatal("expired entries must be pruned") } th.fail("5.6.7.8", now) th.clear("5.6.7.8") if len(th.failures) != 0 { t.Fatal("clear must drop the entry") } } func TestStaticAndLoginPageOpen(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) if resp, _ := doReq(t, ts, reqOpts{path: "/static/dashboard.css"}); resp.StatusCode != http.StatusOK { t.Fatalf("/static/dashboard.css: status=%d, want 200", resp.StatusCode) } resp, body := doReq(t, ts, reqOpts{path: "/login"}) if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="password"`) { t.Fatalf("/login: status=%d", resp.StatusCode) } } func TestLoginNextOpenRedirectRejected(t *testing.T) { for next, want := range map[string]string{ "/ips": "/ips", "/ips?q=1": "/ips?q=1", "//evil.example": "/", "/\\evil.example": "/", "http://evil.example": "/", "https://evil.example/x": "/", "javascript:alert(1)": "/", "": "/", "/a\r\nSet-Cookie: x": "/", } { if got := safeNext(next); got != want { t.Fatalf("safeNext(%q)=%q, want %q", next, got, want) } } _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"//evil.example"}}, headers: map[string]string{"Origin": ts.URL}}) if resp.Header.Get("Location") != "/" { t.Fatalf("Location=%q, want /", resp.Header.Get("Location")) } } func TestControlAPITokenForwarded(t *testing.T) { fake, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, nil) good := login(t, ts) if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK { t.Fatalf("GET /overview: status=%d", resp.StatusCode) } fake.authMu.Lock() defer fake.authMu.Unlock() if len(fake.authHeaders) == 0 { t.Fatal("control-api was never called") } for _, h := range fake.authHeaders { if h != "Bearer ca-admin-token" { t.Fatalf("control-api Authorization=%q, want Bearer ca-admin-token", h) } } } func TestAuthDisabledWithZeroConfig(t *testing.T) { fake, caURL := newFakeControlAPI(t) ts := newTestServer(t, caURL) // zero auth fields resp, body := doReq(t, ts, reqOpts{path: "/overview"}) if resp.StatusCode != http.StatusOK { t.Fatalf("status=%d, want 200 without login", resp.StatusCode) } if strings.Contains(body, "/logout") { t.Fatal("logout control must be hidden when auth is disabled") } if resp, _ := doReq(t, ts, reqOpts{path: "/login"}); resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" { t.Fatalf("/login with auth disabled: status=%d loc=%q, want redirect to /", resp.StatusCode, resp.Header.Get("Location")) } fake.authMu.Lock() defer fake.authMu.Unlock() for _, h := range fake.authHeaders { if h != "" { t.Fatalf("no token configured but Authorization=%q was sent", h) } } } func TestAuthDisabledWhenOnlyUsernameSet(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, func(c *Config) { c.Password = "" }) if resp, _ := doReq(t, ts, reqOpts{path: "/overview"}); resp.StatusCode != http.StatusOK { t.Fatalf("status=%d, want 200 (auth disabled without password)", resp.StatusCode) } } func TestEmptySessionSecretUsesRandomKey(t *testing.T) { _, caURL := newFakeControlAPI(t) _, ts := newAuthTestServer(t, caURL, func(c *Config) { c.SessionSecret = "" }) good := login(t, ts) if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK { t.Fatalf("status=%d, want 200 with random-key session", resp.StatusCode) } }