6030a985fa
Adds clientIP() (reads the rightmost X-Forwarded-For hop via Header.Values, since Traefik appends the peer address it actually observed and the leftmost entries are client-controlled) and loginLimiter, an in-memory per-IP counter that blocks after loginMaxFailures within loginWindow. No routes wire these up yet — that lands in Task 5.
225 lines
5.9 KiB
Go
225 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSessionRoundTrip(t *testing.T) {
|
|
key := sessionKey("token-abc")
|
|
now := time.Now().UnixMilli()
|
|
value := signSession(key, now+60_000)
|
|
if !verifySession(key, value, now) {
|
|
t.Fatal("verifySession = false for a freshly signed cookie, want true")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejects(t *testing.T) {
|
|
key := sessionKey("token-abc")
|
|
now := time.Now().UnixMilli()
|
|
valid := signSession(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", signSession(key, now-1)},
|
|
{"tampered signature", payload + "." + flipLastChar(sig)},
|
|
{"tampered expiry", "99999999999999." + sig},
|
|
{"signed with another key", signSession(sessionKey("other-token"), now+60_000)},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if verifySession(key, tc.value, now) {
|
|
t.Fatalf("verifySession(%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 := sessionKey("token-a")
|
|
b := sessionKey("token-b")
|
|
if string(a) == string(b) {
|
|
t.Fatal("sessionKey collided for different API tokens")
|
|
}
|
|
}
|
|
|
|
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()
|
|
setSessionCookie(rr, r, sessionKey("token-abc"))
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
c := cookies[0]
|
|
if c.Name != sessionCookieName {
|
|
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
|
|
}
|
|
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()
|
|
clearSessionCookie(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 < loginMaxFailures; i++ {
|
|
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
|
|
}
|
|
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, loginMaxFailures)
|
|
}
|
|
if wait > loginWindow {
|
|
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
|
l := newLoginLimiter()
|
|
start := time.Now()
|
|
for i := 0; i < loginMaxFailures; 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(loginWindow + 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 < loginMaxFailures; 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 < loginMaxFailures; 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)
|
|
}
|
|
}
|