diff --git a/AI.md b/AI.md index cfaeae77..4b09d46f 100644 --- a/AI.md +++ b/AI.md @@ -284,18 +284,36 @@ can access the Session. `Request.Get` returns nil and `Request.Set` is a no-op when no Session exists. `Jaws.Close` invalidates every Session, clears its data, and prevents new Session creation. +Maintenance checks Session expiry every tenth pass; expired Sessions count +until cleanup. `MaxSessions` caps registered Sessions globally, while +`MaxSessionsPerIP` caps them per client address bucket. Both default to zero +(disabled). Set the per-IP cap below the global cap to reserve capacity for +other buckets. `SessionMiddleware` skips its handler and returns HTTP 429 at +the per-IP cap while global capacity remains, or HTTP 503 at the global cap +or on other creation failure. +Existing Sessions remain usable. `NewSession` returns nil when creation fails; +`AutoSession` may leave a Request without one, so use the middleware when a +Session is required. Rotation needs a free slot under each enabled cap and +preserves the old Session on refusal. + +One client making cookie-less requests can fill `MaxSessions`; a per-IP cap +limits one bucket, but shared addresses share that limit and clients using +multiple addresses can still exhaust the global cap. Rate-limit +Session-creating routes at the proxy. + Loopback addresses are treated as the same client so a loopback reverse proxy does not break binding. If all traffic reaches JaWS from loopback, binding is -effectively disabled unless trusted forwarding is configured behind a single -controlled proxy. `CookieName` must be a valid non-empty HTTP cookie name; its -default derives from the executable and falls back to `jaws`. +effectively disabled and visitors using the same proxy address share the per-IP +cap unless trusted forwarding is configured behind a single controlled proxy. +`CookieName` must be a valid non-empty HTTP cookie name; its default derives +from the executable and falls back to `jaws`. ## Configuration and logging Set all exported `Jaws` configuration fields immediately after `New` and before -exposing handlers, creating Requests, or starting a serve loop. They are ordinary -fields, not synchronized live settings. If `Debug` or the resource list changes, -call `GenerateHeadHTML` before rendering more pages. +exposing handlers, creating Sessions or Requests, or starting a serve loop. +They are ordinary fields, not synchronized live settings. If `Debug` or the +resource list changes, call `GenerateHeadHTML` before rendering more pages. `GenerateHeadHTML` emits common JavaScript and CSS URLs, recognizes image and font resources, and passes parsed URLs to automatic Content-Security-Policy diff --git a/jaws.go b/jaws.go index 20965d70..694d6712 100644 --- a/jaws.go +++ b/jaws.go @@ -83,11 +83,11 @@ type Jid = jid.Jid // convenience alias // Except for [Jaws.StatusMetrics], the exported configuration fields are ordinary // fields, not live synchronized settings. Several are consulted on each connection // or request (for example MaxPendingRequestsPerIP and WebSocketPingInterval), so set -// them all before exposing handlers, creating Requests, or starting [Jaws.Serve] / -// [Jaws.ServeWithTimeout]; mutating one after serving has begun is an unsynchronized -// write and is not supported. StatusMetrics is atomic and may be changed while -// serving. Methods document their own concurrency behavior and may be called -// concurrently when stated. +// them all before exposing handlers, creating Sessions or Requests, or starting +// [Jaws.Serve] / [Jaws.ServeWithTimeout]; mutating one after serving has begun +// is an unsynchronized write and is not supported. StatusMetrics is atomic and +// may be changed while serving. Methods document their own concurrency behavior +// and may be called concurrently when stated. type Jaws struct { // CookieName is the name used for session cookies. // @@ -95,7 +95,7 @@ type Jaws struct { // executable and falls back to "jaws". CookieName must be a valid, non-empty // HTTP cookie name; see [http.Cookie.Valid]. CookieName string - AutoSession bool // Create and associate a session during a successful WebSocket upgrade when a Request has none. Defaults to false. + AutoSession bool // Create a session during a successful WebSocket upgrade when a Request has none and the Session limits allow it. Defaults to false. // TrustForwardedHeaders enables trusted proxy header processing. // // It governs the session cookie Secure flag and WebSocket Origin scheme @@ -140,6 +140,20 @@ type Jaws struct { // It defaults to [DefaultWebSocketPingInterval] and must be positive; // non-positive values do not disable probing. WebSocketPingInterval time.Duration + // MaxSessions limits the number of registered Sessions. + // + // A non-positive value disables the cap, which is the default. Sessions + // waiting for expiry cleanup count toward it. At the limit, new Session + // creation returns nil without evicting existing Sessions. + MaxSessions int + // MaxSessionsPerIP limits registered Sessions per client address bucket. + // + // Buckets match [Jaws.MaxPendingRequestsPerIP] and use the client IP selected + // by [Jaws.TrustForwardedHeaders]. A non-positive value disables the cap, + // which is the default. Existing Sessions remain usable at the limit; + // [Jaws.SessionMiddleware] returns HTTP 429 for new ones if global capacity + // remains. + MaxSessionsPerIP int // MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket. // // IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other @@ -177,6 +191,8 @@ type Jaws struct { requestCount int // number of non-nil entries in requests pending map[netip.Addr][]*Request sessions map[key.Key]*Session + sessionBucketCounts map[netip.Addr]int + sessionSweep uint8 // maintenance passes since the last Session expiry scan dirty map[any]int dirtOrder int } @@ -210,6 +226,7 @@ func New() (jw *Jaws, err error) { requests: make(map[key.Key]*Request), pending: make(map[netip.Addr][]*Request), sessions: make(map[key.Key]*Session), + sessionBucketCounts: make(map[netip.Addr]int), dirty: make(map[any]int), closeCh: make(chan struct{}), } diff --git a/jaws_test.go b/jaws_test.go index 806ddb10..64bf030b 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -3622,7 +3622,7 @@ func newBenchRequest(b *testing.B, n int) *Request { func newUnpooledBenchRequest(jw *Jaws) (rq *Request) { remoteIP := jw.clientIP(nil) - bucketKey := pendingBucketKey(remoteIP) + bucketKey := clientBucketKey(remoteIP) jw.mu.Lock() defer jw.mu.Unlock() jw.limitPendingRequestsLocked(bucketKey) diff --git a/request.go b/request.go index a14c0582..d950613c 100644 --- a/request.go +++ b/request.go @@ -379,7 +379,7 @@ func (rq *Request) newAutoSession(r *http.Request) (sess *Session) { rq.mu.Lock() defer rq.mu.Unlock() if rq.session == nil { - if sess = jw.newSessionLocked(remoteIP, secure); sess != nil { + if sess, _ = jw.newSessionLocked(remoteIP, secure); sess != nil { sess.addRequest(rq) rq.session = sess jw.registerSessionLocked(sess, true) diff --git a/request_test.go b/request_test.go index e35d822c..616b0613 100644 --- a/request_test.go +++ b/request_test.go @@ -3151,11 +3151,13 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) { } // Dead session cleanup path. - sess := jw.newSession(nil, hr) + sess, _ := jw.newSession(nil, hr) sess.mu.Lock() sess.deadline = time.Now().Add(-time.Second) sess.mu.Unlock() - jw.maintenance(time.Second) + for range 10 { + jw.maintenance(time.Second) + } if got := jw.SessionCount(); got != 0 { t.Fatalf("expected dead session cleanup, got %d", got) } diff --git a/requestpool.go b/requestpool.go index 94c43491..e0fffbe5 100644 --- a/requestpool.go +++ b/requestpool.go @@ -67,9 +67,9 @@ func (jw *Jaws) NewRequest(w http.ResponseWriter, r *http.Request) *Request { var wellKnownNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96") -// pendingBucketKey uses the embedded IPv4 address for the well-known NAT64 prefix. +// clientBucketKey uses the embedded IPv4 address for the well-known NAT64 prefix. // Other IPv6 addresses share a /64; IPv4 addresses use their full address. -func pendingBucketKey(addr netip.Addr) netip.Addr { +func clientBucketKey(addr netip.Addr) netip.Addr { addr = addr.Unmap() if wellKnownNAT64Prefix.Contains(addr) { a := addr.As16() @@ -83,7 +83,7 @@ func pendingBucketKey(addr netip.Addr) netip.Addr { func (jw *Jaws) newRequest(r *http.Request) (rq *Request) { remoteIP := jw.clientIP(r) - bucketKey := pendingBucketKey(remoteIP) + bucketKey := clientBucketKey(remoteIP) func() { jw.mu.Lock() @@ -208,7 +208,7 @@ func (jw *Jaws) pendingEvictionVictimLocked(bucketKey netip.Addr, nowSeconds int } func (jw *Jaws) removePendingRequestLocked(rq *Request) { - bucketKey := pendingBucketKey(rq.remoteIP) + bucketKey := clientBucketKey(rq.remoteIP) pending := jw.pending[bucketKey] if i := slices.Index(pending, rq); i >= 0 { pending = slices.Delete(pending, i, i+1) diff --git a/serve.go b/serve.go index dfd460ed..a21282ae 100644 --- a/serve.go +++ b/serve.go @@ -226,9 +226,14 @@ func (jw *Jaws) maintenance(requestTimeout time.Duration) { jw.retireNonRunningRequestLocked(rq) } } - for _, sess := range jw.sessions { - if sess.isDead() { - jw.deleteSessionIfCurrentLocked(sess) + // Unattached Sessions cannot expire until their one-minute deadline. + jw.sessionSweep++ + if jw.sessionSweep == 10 { + jw.sessionSweep = 0 + for _, sess := range jw.sessions { + if sess.isDead() { + jw.deleteSessionIfCurrentLocked(sess) + } } } jw.updateStatusLocked() diff --git a/serve_test.go b/serve_test.go index df1fe2b2..3fd2d3fd 100644 --- a/serve_test.go +++ b/serve_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/netip" "net/url" "path" "slices" @@ -423,6 +424,53 @@ func TestJaws_ServeOverloadLoggerCanBroadcastRepeatedly(t *testing.T) { }) } +// BenchmarkJawsMaintenanceSessions measures maintenance with 10,000 live Sessions. +func BenchmarkJawsMaintenanceSessions(b *testing.B) { + jw, err := New() + if err != nil { + b.Fatal(err) + } + b.Cleanup(jw.Close) + jw.mu.Lock() + for range 10_000 { + sess, _ := jw.newSessionLocked(netip.Addr{}, false) + jw.registerSessionLocked(sess, false) + } + jw.mu.Unlock() + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + jw.maintenance(time.Hour) + } +} + +func TestJawsMaintenanceSweepsSessionsEveryTenTicks(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + sess := jw.NewSession(nil, httptest.NewRequest(http.MethodGet, "/", nil)) + if sess == nil { + t.Fatal("NewSession returned nil") + } + sess.mu.Lock() + sess.deadline = time.Now().Add(-time.Second) + sess.mu.Unlock() + + for i := range 9 { + jw.maintenance(time.Hour) + if got := jw.SessionCount(); got != 1 { + t.Fatalf("session count after tick %d = %d, want 1", i+1, got) + } + } + jw.maintenance(time.Hour) + if got := jw.SessionCount(); got != 0 { + t.Fatalf("session count after tenth tick = %d, want 0", got) + } +} + func TestJaws_MaintenanceRetiresExpiredRequestOnce(t *testing.T) { jw, err := New() if err != nil { diff --git a/session.go b/session.go index 71d969c4..072f743c 100644 --- a/session.go +++ b/session.go @@ -171,8 +171,8 @@ func (sess *Session) Cookie() (cookie *http.Cookie) { return } -// addCookie adds sess's cookie to w and r while sess is current and live. -func (sess *Session) addCookie(w http.ResponseWriter, r *http.Request) { +// addCookie reports whether sess's live cookie was added to r and optionally w. +func (sess *Session) addCookie(w http.ResponseWriter, r *http.Request) (added bool) { var h http.Header if w != nil { // ResponseWriter.Header is caller code and may re-enter Jaws, including @@ -197,7 +197,9 @@ func (sess *Session) addCookie(w http.ResponseWriter, r *http.Request) { } } r.AddCookie(&cookie) + added = true } + return } // Close invalidates and expires the [Session]. @@ -291,7 +293,8 @@ func (sess *Session) Broadcast(msg wire.Message) { // SessionCount returns the number of registered Sessions. // -// It includes Sessions retained during their disconnect grace period. +// It includes Sessions in their disconnect grace period and expired Sessions +// awaiting maintenance cleanup. func (jw *Jaws) SessionCount() (n int) { jw.mu.RLock() n = len(jw.sessions) @@ -376,10 +379,10 @@ func (jw *Jaws) GetSession(r *http.Request) (sess *Session) { // NewSession creates a new [Session]. // -// All live pre-existing [Session] values referenced by matching cookies and -// bound to the request's client IP are cleared and closed. Each is closed with -// [Session.Close], so the JaWS processing loop ([Jaws.Serve] or -// [Jaws.ServeWithTimeout]) must be running. +// When creation succeeds, live pre-existing [Session] values referenced by +// matching cookies and bound to the request's client IP are cleared and closed. +// Each is closed with [Session.Close], so the JaWS processing loop ([Jaws.Serve] +// or [Jaws.ServeWithTimeout]) must be running. // // Subsequent [Request] values created with [Jaws.NewRequest] that have the // cookie set and originate from the same IP will be able to access the [Session]. @@ -393,14 +396,20 @@ func (jw *Jaws) GetSession(r *http.Request) (sess *Session) { // of the same HTTP request. If a concurrent [Session.Close] wins first, neither // w nor r receives its live cookie. // -// It returns nil and has no effect if r is nil or shutdown has begun; w may be -// nil. +// It returns nil without closing matching Sessions if r is nil, shutdown has +// begun, [Jaws.MaxSessions] or [Jaws.MaxSessionsPerIP] is reached, or cookie +// publication fails; w may be nil. +// +// Replacing a Session requires a free slot under each enabled limit until the +// new cookie is published and the old Session is closed. // // It panics if the [crypto/rand.Reader] captured by [New] returns an error while // generating the session ID. Go's default reader does not return errors. func (jw *Jaws) NewSession(w http.ResponseWriter, r *http.Request) (sess *Session) { if r != nil { - if sessionIDs := getCookieSessionsIDs(r.Header, jw.CookieName); len(sessionIDs) > 0 { + sessionIDs := getCookieSessionsIDs(r.Header, jw.CookieName) + sess, _ = jw.newSession(w, r) + if sess != nil && len(sessionIDs) > 0 { remoteIP := jw.clientIP(r) for _, sessionID := range sessionIDs { jw.mu.RLock() @@ -412,39 +421,46 @@ func (jw *Jaws) NewSession(w http.ResponseWriter, r *http.Request) (sess *Sessio } } } - sess = jw.newSession(w, r) } return } -func (jw *Jaws) newSession(w http.ResponseWriter, r *http.Request) (sess *Session) { +func (jw *Jaws) newSession(w http.ResponseWriter, r *http.Request) (sess *Session, limitIP bool) { secure := secureheaders.RequestIsSecure(r, jw.TrustForwardedHeaders) remoteIP := jw.clientIP(r) func() { jw.mu.Lock() defer jw.mu.Unlock() - sess = jw.newSessionLocked(remoteIP, secure) + sess, limitIP = jw.newSessionLocked(remoteIP, secure) if sess != nil { jw.registerSessionLocked(sess, false) } }() - if sess != nil { - sess.addCookie(w, r) + if sess != nil && !sess.addCookie(w, r) { + sess = nil } return } // newSessionLocked allocates a Session whose ID is absent from jw.sessions, or -// returns nil after shutdown begins. +// returns nil after shutdown begins or a Session limit is reached. limitIP +// distinguishes the per-client limit for SessionMiddleware's HTTP response. // // The caller must hold jw.mu and publish any returned Session before releasing // it. -func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Session) { +func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Session, limitIP bool) { select { case <-jw.closeCh: return default: } + if jw.MaxSessions > 0 && len(jw.sessions) >= jw.MaxSessions { + return + } + if jw.MaxSessionsPerIP > 0 && jw.sessionBucketCounts[clientBucketKey(remoteIP)] >= jw.MaxSessionsPerIP { + limitIP = true + return + } // Retired IDs deliberately remain eligible for reuse. A natural 64-bit random // collision can therefore make a stale cookie name a later Session. Preventing // every reuse would require unbounded tombstones; if this probability/space @@ -464,6 +480,9 @@ func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Sessio // attached to a running Request. The caller must hold jw.mu. func (jw *Jaws) registerSessionLocked(sess *Session, active bool) { jw.sessions[sess.sessionID] = sess + if jw.MaxSessionsPerIP > 0 { + jw.sessionBucketCounts[clientBucketKey(sess.remoteIP)]++ + } metrics := StatusMetricSessions if active { metrics |= StatusMetricActiveSessions @@ -482,6 +501,7 @@ func (jw *Jaws) closeSessionsLocked() { sess.mu.Unlock() } jw.sessions = nil + jw.sessionBucketCounts = nil } // deleteSessionIfCurrent unregisters sess only while it still owns its ID. @@ -498,6 +518,13 @@ func (jw *Jaws) deleteSessionIfCurrent(sess *Session) { func (jw *Jaws) deleteSessionIfCurrentLocked(sess *Session) { if jw.sessions[sess.sessionID] == sess { delete(jw.sessions, sess.sessionID) + if jw.MaxSessionsPerIP > 0 { + bucket := clientBucketKey(sess.remoteIP) + jw.sessionBucketCounts[bucket]-- + if jw.sessionBucketCounts[bucket] <= 0 { + delete(jw.sessionBucketCounts, bucket) + } + } jw.markStatusDirty(StatusMetricSessions | StatusMetricActiveSessions) } } @@ -509,16 +536,24 @@ type sessioner struct { func (sess sessioner) ServeHTTP(w http.ResponseWriter, r *http.Request) { if sess.jw.GetSession(r) == nil { - sess.jw.newSession(w, r) + created, limitIP := sess.jw.newSession(w, r) + if created == nil { + status := http.StatusServiceUnavailable + if limitIP { + status = http.StatusTooManyRequests + } + http.Error(w, http.StatusText(status), status) + return + } } sess.h.ServeHTTP(w, r) } // SessionMiddleware returns a session-creating [http.Handler]. // -// Before invoking h, it creates a JaWS [Session] when the request has none. If -// a concurrent [Session.Close] wins the new Session's cookie publication, h -// runs without that Session or its live cookie. +// Before invoking h, it creates a JaWS [Session] when the request has none. +// A full per-client bucket returns HTTP 429 when global capacity remains; +// other creation failures return HTTP 503. In either case it does not invoke h. // // It is distinct from the session accessors: // [Jaws.GetSession] and [Request.Session] look up an existing [Session], while diff --git a/session_test.go b/session_test.go index d7f78ec3..5c19a1df 100644 --- a/session_test.go +++ b/session_test.go @@ -289,7 +289,9 @@ func TestSession_AddCookieRejectsUnavailableSession(t *testing.T) { rw := httptest.NewRecorder() hr := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) - sess.addCookie(rw, hr) + if sess.addCookie(rw, hr) { + t.Error("unavailable Session published a cookie") + } for _, cookie := range hr.Cookies() { if cookie.Name == jw.CookieName { t.Errorf("request contains unavailable session cookie: %v", cookie) @@ -318,6 +320,25 @@ func (w *closingSessionResponseWriter) Header() http.Header { return w.ResponseRecorder.Header() } +func TestSession_NewSessionCloseDuringResponseHeaderReturnsNil(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + waitForServeLoop(t, jw) + t.Cleanup(jw.Close) + + rw := &closingSessionResponseWriter{ResponseRecorder: httptest.NewRecorder(), jw: jw} + r := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) + if sess := jw.NewSession(rw, r); sess != nil { + t.Fatalf("NewSession after concurrent Close = %p, want nil", sess) + } + if got := jw.SessionCount(); got != 0 { + t.Fatalf("SessionCount() = %d, want 0", got) + } +} + func TestSessionMiddleware_CloseDuringResponseHeader(t *testing.T) { jw, err := New() if err != nil { @@ -331,46 +352,35 @@ func TestSessionMiddleware_CloseDuringResponseHeader(t *testing.T) { jw: jw, } hr := httptest.NewRequest(http.MethodGet, "http://example.test/", nil) - type result struct { - rq *Request - requestCookies []*http.Cookie - handlerCalled bool - panicValue any - } - done := make(chan result, 1) + called := false + done := make(chan any, 1) go func() { - var got result - defer func() { - got.panicValue = recover() - done <- got - }() - h := jw.SessionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got.handlerCalled = true - got.requestCookies = r.Cookies() - got.rq = jw.newRequest(r) + defer func() { done <- recover() }() + h := jw.SessionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true w.WriteHeader(http.StatusNoContent) })) h.ServeHTTP(rw, hr) }() - var got result + var panicValue any select { - case got = <-done: + case panicValue = <-done: case <-time.After(2 * time.Second): t.Fatal("SessionMiddleware deadlocked while ResponseWriter.Header closed the Session") } t.Cleanup(jw.Close) - if got.panicValue != nil { - t.Fatalf("SessionMiddleware panicked while ResponseWriter.Header closed the Session: %v", got.panicValue) + if panicValue != nil { + t.Fatalf("SessionMiddleware panicked while ResponseWriter.Header closed the Session: %v", panicValue) } - if !got.handlerCalled { - t.Fatal("SessionMiddleware did not invoke the wrapped handler") + if called { + t.Fatal("SessionMiddleware invoked the wrapped handler without a Session") } if rw.closedSession == nil { t.Fatal("ResponseWriter.Header did not observe a published Session") } - if sess := got.rq.Session(); sess != nil { - t.Errorf("new Request Session() = %v, want nil", sess) + if status := rw.Code; status != http.StatusServiceUnavailable { + t.Errorf("response status = %d, want 503", status) } if requests := rw.closedSession.Requests(); len(requests) != 0 { t.Errorf("closed Session Requests() = %v, want none", requests) @@ -378,11 +388,6 @@ func TestSessionMiddleware_CloseDuringResponseHeader(t *testing.T) { if count := jw.SessionCount(); count != 0 { t.Errorf("SessionCount() = %d, want 0", count) } - for _, cookie := range got.requestCookies { - if cookie.Name == jw.CookieName { - t.Errorf("wrapped handler request contains closed session cookie: %v", cookie) - } - } for _, cookie := range rw.Result().Cookies() { if cookie.Name == jw.CookieName && cookie.MaxAge >= 0 { t.Errorf("response contains live session cookie: %v", cookie) @@ -531,6 +536,192 @@ func TestSession_NewSessionReplacesDuplicateCookieSessions(t *testing.T) { } } +func TestSession_MaxSessionsRefusesWithoutEviction(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 1 + go jw.Serve() + t.Cleanup(jw.Close) + waitForServeLoop(t, jw) + + firstRequest := httptest.NewRequest(http.MethodGet, "/first", nil) + first := jw.NewSession(httptest.NewRecorder(), firstRequest) + if first == nil { + t.Fatal("first NewSession returned nil") + } + first.Set("value", "kept") + secondResponse := httptest.NewRecorder() + if got := jw.NewSession(secondResponse, httptest.NewRequest(http.MethodGet, "/second", nil)); got != nil { + t.Fatalf("NewSession at cap = %p, want nil", got) + } + if cookies := secondResponse.Result().Cookies(); len(cookies) != 0 { + t.Fatalf("NewSession at cap set cookies: %v", cookies) + } + if got := jw.GetSession(firstRequest); got != first || got.Get("value") != "kept" { + t.Fatalf("original Session after refusal = %p, value %v", got, got.Get("value")) + } + rotation := httptest.NewRequest(http.MethodGet, "/rotate", nil) + rotation.AddCookie(first.Cookie()) + if got := jw.NewSession(httptest.NewRecorder(), rotation); got != nil { + t.Fatalf("NewSession rotation at cap = %p, want nil", got) + } + if got := jw.GetSession(rotation); got != first || got.Get("value") != "kept" { + t.Fatalf("original Session after refused rotation = %p, value %v", got, got.Get("value")) + } + + called := false + wrapped := jw.SessionMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + middlewareResponse := httptest.NewRecorder() + wrapped.ServeHTTP(middlewareResponse, httptest.NewRequest(http.MethodGet, "/wrapped", nil)) + if called || middlewareResponse.Code != http.StatusServiceUnavailable || len(middlewareResponse.Result().Cookies()) != 0 { + t.Fatalf("middleware at cap: called=%v, status=%d, cookies=%v", called, middlewareResponse.Code, middlewareResponse.Result().Cookies()) + } + + first.Close() + if got := jw.NewSession(nil, httptest.NewRequest(http.MethodGet, "/later", nil)); got == nil { + t.Fatal("NewSession after Close returned nil") + } +} + +func TestSession_MaxSessionsPerIP(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 2 + jw.MaxSessionsPerIP = 1 + go jw.Serve() + t.Cleanup(jw.Close) + waitForServeLoop(t, jw) + + newRequest := func(ip string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = net.JoinHostPort(ip, "1234") + return r + } + first := jw.NewSession(nil, newRequest("192.0.2.1")) + if first == nil { + t.Fatal("first NewSession returned nil") + } + if got := jw.NewSession(nil, newRequest("192.0.2.1")); got != nil { + t.Fatalf("NewSession at per-IP limit = %p, want nil", got) + } + + called := 0 + wrapped := jw.SessionMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called++ })) + limited := httptest.NewRecorder() + wrapped.ServeHTTP(limited, newRequest("192.0.2.1")) + if limited.Code != http.StatusTooManyRequests || called != 0 || len(limited.Result().Cookies()) != 0 { + t.Fatalf("per-IP limit: status=%d, called=%d, cookies=%v", limited.Code, called, limited.Result().Cookies()) + } + + existingRequest := newRequest("192.0.2.1") + existingRequest.AddCookie(first.Cookie()) + existing := httptest.NewRecorder() + wrapped.ServeHTTP(existing, existingRequest) + if existing.Code != http.StatusOK || called != 1 || jw.SessionCount() != 1 { + t.Fatalf("existing Session: status=%d, called=%d, sessions=%d", existing.Code, called, jw.SessionCount()) + } + + other := httptest.NewRecorder() + wrapped.ServeHTTP(other, newRequest("198.51.100.1")) + if other.Code != http.StatusOK || called != 2 || jw.SessionCount() != 2 { + t.Fatalf("other IP: status=%d, called=%d, sessions=%d", other.Code, called, jw.SessionCount()) + } + globalLimit := httptest.NewRecorder() + wrapped.ServeHTTP(globalLimit, newRequest("192.0.2.1")) + if globalLimit.Code != http.StatusServiceUnavailable || called != 2 { + t.Fatalf("global limit: status=%d, called=%d", globalLimit.Code, called) + } + + autoRequest := newRequest("192.0.2.1") + rq := jw.newRequest(autoRequest) + if got := rq.newAutoSession(autoRequest); got != nil || jw.SessionCount() != 2 { + t.Fatalf("AutoSession at per-IP limit = %p, sessions=%d", got, jw.SessionCount()) + } + + first.Close() + afterClose := httptest.NewRecorder() + wrapped.ServeHTTP(afterClose, newRequest("192.0.2.1")) + if afterClose.Code != http.StatusOK || called != 3 || jw.SessionCount() != 2 { + t.Fatalf("after Close: status=%d, called=%d, sessions=%d", afterClose.Code, called, jw.SessionCount()) + } +} + +func TestSession_MaxSessionsPerIPRotationNeedsSlot(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 3 + jw.MaxSessionsPerIP = 2 + go jw.Serve() + t.Cleanup(jw.Close) + waitForServeLoop(t, jw) + + newRequest := func() *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/login", nil) + r.RemoteAddr = "192.0.2.1:1234" + return r + } + first := jw.NewSession(nil, newRequest()) + second := jw.NewSession(nil, newRequest()) + if first == nil || second == nil { + t.Fatalf("Sessions before cap: first=%p, second=%p", first, second) + } + first.Set("login", "kept") + rotation := newRequest() + rotation.AddCookie(first.Cookie()) + if got := jw.NewSession(nil, rotation); got != nil { + t.Fatalf("rotation at per-IP cap = %p, want nil", got) + } + if got := jw.GetSession(rotation); got != first || got.Get("login") != "kept" { + t.Fatalf("Session after refused rotation = %p, value %v", got, got.Get("login")) + } + + first.Close() + if got := jw.NewSession(nil, newRequest()); got == nil || jw.SessionCount() != 2 { + t.Fatalf("NewSession after Close = %p, sessions=%d", got, jw.SessionCount()) + } +} + +func TestSession_MaxSessionsPerIPBuckets(t *testing.T) { + tests := []struct { + name, first, same, other string + }{ + {"IPv4 mapped", "203.0.113.1", "::ffff:203.0.113.1", "203.0.113.2"}, + {"IPv6 /64", "2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:3::1"}, + {"NAT64", "64:ff9b::cb00:7101", "64:ff9b::cb00:7101", "64:ff9b::c633:6401"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + jw.MaxSessionsPerIP = 1 + newRequest := func(ip string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = net.JoinHostPort(ip, "1234") + return r + } + if sess := jw.NewSession(nil, newRequest(tt.first)); sess == nil { + t.Fatal("first NewSession returned nil") + } + if sess := jw.NewSession(nil, newRequest(tt.same)); sess != nil { + t.Fatalf("same bucket NewSession = %p, want nil", sess) + } + if sess := jw.NewSession(nil, newRequest(tt.other)); sess == nil { + t.Fatal("other bucket NewSession returned nil") + } + }) + } +} + func TestSession_NewSessionIgnoresDeadMappedSession(t *testing.T) { jw, err := New() if err != nil { @@ -1809,13 +2000,13 @@ func TestSession_CloseDoesNotDeleteSameIDReplacement(t *testing.T) { remoteIP := netip.MustParseAddr("192.0.2.1") stale := newSession(jw, sessionID, remoteIP, false) jw.mu.Lock() - jw.sessions[sessionID] = stale + jw.registerSessionLocked(stale, false) jw.mu.Unlock() jw.deleteSessionIfCurrent(stale) replacement := newSession(jw, sessionID, remoteIP, false) jw.mu.Lock() - jw.sessions[sessionID] = replacement + jw.registerSessionLocked(replacement, false) jw.mu.Unlock() if cookie := stale.Close(); cookie == nil || cookie.MaxAge != -1 { diff --git a/status_test.go b/status_test.go index e2ed535e..a59c5e46 100644 --- a/status_test.go +++ b/status_test.go @@ -967,7 +967,9 @@ func TestJaws_SessionCountTag(t *testing.T) { expired.mu.Lock() expired.deadline = time.Now().Add(-time.Second) expired.mu.Unlock() - jw.maintenance(time.Hour) + for range 10 { + jw.maintenance(time.Hour) + } if got := jw.SessionCount(); got != 0 { t.Fatalf("SessionCount() = %d, want 0", got) }