package session import ( "crypto/tls" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestSessionRoundTrip(t *testing.T) { key := Key("token-abc", "pw-abc") now := time.Now().UnixMilli() value := Sign(key, now+60_000) if !Verify(key, value, now) { t.Fatal("Verify = false for a freshly signed cookie, want true") } } func TestSessionRejects(t *testing.T) { key := Key("token-abc", "pw-abc") now := time.Now().UnixMilli() valid := Sign(key, now+60_000) payload, sig, _ := strings.Cut(valid, ".") cases := []struct { name string value string }{ {"empty", ""}, {"no separator", payload + sig}, {"unparseable expiry", "notanumber." + sig}, {"expired", Sign(key, now-1)}, {"tampered signature", payload + "." + flipLastChar(sig)}, {"tampered expiry", "99999999999999." + sig}, {"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if Verify(key, tc.value, now) { t.Fatalf("Verify(%q) = true, want false", tc.value) } }) } } func flipLastChar(s string) string { if s == "" { return "x" } last := s[len(s)-1] if last == 'A' { return s[:len(s)-1] + "B" } return s[:len(s)-1] + "A" } func TestSessionKeyDependsOnToken(t *testing.T) { a := Key("token-a", "pw-abc") b := Key("token-b", "pw-abc") if string(a) == string(b) { t.Fatal("Key collided for different API tokens") } } func TestSessionKeyDependsOnWebPassword(t *testing.T) { a := Key("token-abc", "pw-a") b := Key("token-abc", "pw-b") if string(a) == string(b) { t.Fatal("Key collided for different web passwords with the same API token") } } func TestSetSessionCookieAttributes(t *testing.T) { cases := []struct { name string tls bool forwarded string wantSecure bool }{ {"plain http dev", false, "", false}, {"direct tls", true, "", true}, {"behind https proxy", false, "https", true}, {"behind http proxy", false, "http", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/login", nil) if tc.tls { r.TLS = &tls.ConnectionState{} } if tc.forwarded != "" { r.Header.Set("X-Forwarded-Proto", tc.forwarded) } rr := httptest.NewRecorder() SetCookie(rr, r, Key("token-abc", "pw-abc")) cookies := rr.Result().Cookies() if len(cookies) != 1 { t.Fatalf("got %d cookies, want 1", len(cookies)) } c := cookies[0] if c.Name != CookieName { t.Fatalf("cookie name = %q, want %q", c.Name, CookieName) } if !c.HttpOnly { t.Fatal("cookie HttpOnly = false, want true") } if c.SameSite != http.SameSiteLaxMode { t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite) } if c.Path != "/" { t.Fatalf("cookie Path = %q, want /", c.Path) } if c.Secure != tc.wantSecure { t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure) } if c.MaxAge != int(sessionTTL/time.Second) { t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second)) } }) } } func TestClearSessionCookie(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/logout", nil) rr := httptest.NewRecorder() ClearCookie(rr, r) cookies := rr.Result().Cookies() if len(cookies) != 1 { t.Fatalf("got %d cookies, want 1", len(cookies)) } if cookies[0].MaxAge >= 0 { t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge) } } func TestClientIP(t *testing.T) { cases := []struct { name string remoteAddr string xff []string want string }{ {"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"}, {"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"}, { // The client sent "1.2.3.4" itself; Traefik appended the address it // actually saw. Only the rightmost entry is trustworthy. name: "spoofed left entry is ignored", remoteAddr: "10.0.0.1:5555", xff: []string{"1.2.3.4, 203.0.113.9"}, want: "203.0.113.9", }, { name: "spoofed separate header line is ignored", remoteAddr: "10.0.0.1:5555", xff: []string{"1.2.3.4", "203.0.113.9"}, want: "203.0.113.9", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/login", nil) r.RemoteAddr = tc.remoteAddr for _, v := range tc.xff { r.Header.Add("X-Forwarded-For", v) } if got := ClientIP(r); got != tc.want { t.Fatalf("ClientIP() = %q, want %q", got, tc.want) } }) } } func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { l := NewLoginLimiter() now := time.Now() for i := 0; i < MaxFailures; i++ { if wait := l.RetryAfter("1.2.3.4", now); wait != 0 { t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures) } l.Fail("1.2.3.4", now) } wait := l.RetryAfter("1.2.3.4", now) if wait <= 0 { t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures) } if wait > Window { t.Fatalf("retryAfter = %v, want <= %v", wait, Window) } } func TestLoginLimiterWindowExpires(t *testing.T) { l := NewLoginLimiter() start := time.Now() for i := 0; i < MaxFailures; i++ { l.Fail("1.2.3.4", start) } if l.RetryAfter("1.2.3.4", start) == 0 { t.Fatal("expected block immediately after the failures") } later := start.Add(Window + time.Second) if wait := l.RetryAfter("1.2.3.4", later); wait != 0 { t.Fatalf("retryAfter = %v once the window passed, want 0", wait) } } func TestLoginLimiterResetClearsCounter(t *testing.T) { l := NewLoginLimiter() now := time.Now() for i := 0; i < MaxFailures; i++ { l.Fail("1.2.3.4", now) } l.Reset("1.2.3.4") if wait := l.RetryAfter("1.2.3.4", now); wait != 0 { t.Fatalf("retryAfter = %v after reset, want 0", wait) } } func TestLoginLimiterIsPerIP(t *testing.T) { l := NewLoginLimiter() now := time.Now() for i := 0; i < MaxFailures; i++ { l.Fail("1.2.3.4", now) } if wait := l.RetryAfter("5.6.7.8", now); wait != 0 { t.Fatalf("retryAfter for a different IP = %v, want 0", wait) } }