From 708d9b4cb773888a5475c92115d6d0ab1238923f Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 16:39:23 +0200 Subject: [PATCH 1/6] Limit session admission and reduce expiry scans --- AI.md | 7 +++ jaws.go | 9 +++- request_test.go | 4 +- serve.go | 11 +++-- serve_test.go | 51 ++++++++++++++++++++ session.go | 45 +++++++++++------- session_test.go | 121 ++++++++++++++++++++++++++++++++++++------------ status_test.go | 4 +- 8 files changed, 198 insertions(+), 54 deletions(-) diff --git a/AI.md b/AI.md index cfaeae77..b81b4bac 100644 --- a/AI.md +++ b/AI.md @@ -284,6 +284,13 @@ 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 removes expired Sessions every tenth pass. `MaxSessions` can limit +registered Sessions; its default value of zero leaves the limit disabled. At +the limit, `NewSession` returns nil and `SessionMiddleware` responds with HTTP +503 without calling its handler. `AutoSession` may leave the Request without one; +use `SessionMiddleware` when a session is required. Expired Sessions count until +cleanup. + 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 diff --git a/jaws.go b/jaws.go index 20965d70..e57b26d3 100644 --- a/jaws.go +++ b/jaws.go @@ -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 [Jaws.MaxSessions] allows it. Defaults to false. // TrustForwardedHeaders enables trusted proxy header processing. // // It governs the session cookie Secure flag and WebSocket Origin scheme @@ -140,6 +140,12 @@ 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 // MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket. // // IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other @@ -177,6 +183,7 @@ type Jaws struct { requestCount int // number of non-nil entries in requests pending map[netip.Addr][]*Request sessions map[key.Key]*Session + sessionSweep uint8 // maintenance passes since the last Session expiry scan dirty map[any]int dirtOrder int } diff --git a/request_test.go b/request_test.go index e35d822c..4b609879 100644 --- a/request_test.go +++ b/request_test.go @@ -3155,7 +3155,9 @@ func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) { 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/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..d318631c 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,56 @@ func TestJaws_ServeOverloadLoggerCanBroadcastRepeatedly(t *testing.T) { }) } +// BenchmarkJawsMaintenanceSessions stresses maintenance scans and their lock +// contention 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() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + 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..377271f9 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,17 @@ 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 +// It returns nil without closing matching Sessions if r is nil, shutdown has +// begun, [Jaws.MaxSessions] is reached, or cookie publication fails; w may be // nil. // // 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,7 +418,6 @@ func (jw *Jaws) NewSession(w http.ResponseWriter, r *http.Request) (sess *Sessio } } } - sess = jw.newSession(w, r) } return } @@ -428,14 +433,14 @@ func (jw *Jaws) newSession(w http.ResponseWriter, r *http.Request) (sess *Sessio 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 the Session limit is reached. // // The caller must hold jw.mu and publish any returned Session before releasing // it. @@ -445,6 +450,9 @@ func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Sessio return default: } + if jw.MaxSessions > 0 && len(jw.sessions) >= jw.MaxSessions { + 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 @@ -508,17 +516,18 @@ type sessioner struct { } func (sess sessioner) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if sess.jw.GetSession(r) == nil { - sess.jw.newSession(w, r) + if sess.jw.GetSession(r) == nil && sess.jw.newSession(w, r) == nil { + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + 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. +// If creation fails or the Session becomes unavailable during cookie publication, +// it responds with HTTP 503 without invoking 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..10150228 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,62 @@ func TestSession_NewSessionReplacesDuplicateCookieSessions(t *testing.T) { } } +func TestSession_MaxSessionsRefusesWithoutEviction(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 1 + serveDone := make(chan struct{}) + go func() { + jw.Serve() + close(serveDone) + }() + t.Cleanup(func() { + jw.Close() + <-serveDone + }) + 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_NewSessionIgnoresDeadMappedSession(t *testing.T) { jw, err := New() if err != nil { 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) } From a6dec1a09e9084b00bccbb482ae8008f43160a74 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 16:51:09 +0200 Subject: [PATCH 2/6] Document session cap risk and measure sequential maintenance --- AI.md | 4 +++- serve_test.go | 11 ++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/AI.md b/AI.md index b81b4bac..bc30275a 100644 --- a/AI.md +++ b/AI.md @@ -289,7 +289,9 @@ registered Sessions; its default value of zero leaves the limit disabled. At the limit, `NewSession` returns nil and `SessionMiddleware` responds with HTTP 503 without calling its handler. `AutoSession` may leave the Request without one; use `SessionMiddleware` when a session is required. Expired Sessions count until -cleanup. +cleanup. `MaxSessions` is a memory ceiling, not abuse protection. One client +making cookie-less requests can fill it, denying new visitors Sessions until +capacity is freed. 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 diff --git a/serve_test.go b/serve_test.go index d318631c..64be7906 100644 --- a/serve_test.go +++ b/serve_test.go @@ -424,8 +424,7 @@ func TestJaws_ServeOverloadLoggerCanBroadcastRepeatedly(t *testing.T) { }) } -// BenchmarkJawsMaintenanceSessions stresses maintenance scans and their lock -// contention with 10,000 live Sessions. +// BenchmarkJawsMaintenanceSessions measures maintenance with 10,000 live Sessions. func BenchmarkJawsMaintenanceSessions(b *testing.B) { jw, err := New() if err != nil { @@ -441,11 +440,9 @@ func BenchmarkJawsMaintenanceSessions(b *testing.B) { b.ReportAllocs() b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - jw.maintenance(time.Hour) - } - }) + for b.Loop() { + jw.maintenance(time.Hour) + } } func TestJawsMaintenanceSweepsSessionsEveryTenTicks(t *testing.T) { From ef0ba3f7fd2ac748368f577d9afbdc0a28feff90 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 17:08:29 +0200 Subject: [PATCH 3/6] Limit registered sessions per client address bucket --- AI.md | 35 +++++++++------ jaws.go | 23 +++++++--- jaws_test.go | 2 +- request.go | 2 +- request_test.go | 2 +- requestpool.go | 8 ++-- serve_test.go | 2 +- session.go | 51 ++++++++++++++++------ session_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 193 insertions(+), 42 deletions(-) diff --git a/AI.md b/AI.md index bc30275a..2872af08 100644 --- a/AI.md +++ b/AI.md @@ -284,27 +284,34 @@ 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 removes expired Sessions every tenth pass. `MaxSessions` can limit -registered Sessions; its default value of zero leaves the limit disabled. At -the limit, `NewSession` returns nil and `SessionMiddleware` responds with HTTP -503 without calling its handler. `AutoSession` may leave the Request without one; -use `SessionMiddleware` when a session is required. Expired Sessions count until -cleanup. `MaxSessions` is a memory ceiling, not abuse protection. One client -making cookie-less requests can fill it, denying new visitors Sessions until -capacity is freed. Rate-limit Session-creating routes at the proxy. +Maintenance removes expired Sessions every tenth pass. `MaxSessions` limits +registered Sessions; zero disables it. When admission hits the global limit, +`NewSession` returns nil and `SessionMiddleware` responds with HTTP 503 without +calling its handler. `AutoSession` may leave the Request without one; use +`SessionMiddleware` when a session is required. Expired Sessions count until +cleanup. + +`MaxSessionsPerIP` defaults to zero (disabled) and counts all registered +Sessions per client address bucket. Set it below `MaxSessions` to reserve +capacity for other buckets. A full bucket makes `SessionMiddleware` return HTTP +429 while existing Sessions remain usable; the global 503 takes precedence. +Without this cap, one client making cookie-less requests can fill `MaxSessions`. +Clients in one bucket share the cap, 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 e57b26d3..b37b7ae1 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 a session during a successful WebSocket upgrade when a Request has none and [Jaws.MaxSessions] allows it. 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 @@ -146,6 +146,16 @@ type Jaws struct { // 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. + // + // IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other + // IPv6 addresses use a /64. All registered Sessions count, including active + // ones and those awaiting cleanup. 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 unless the global + // cap is also reached. The bucket uses the client IP selected by + // [Jaws.TrustForwardedHeaders]. + MaxSessionsPerIP int // MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket. // // IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other @@ -183,6 +193,7 @@ 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 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 4b609879..616b0613 100644 --- a/request_test.go +++ b/request_test.go @@ -3151,7 +3151,7 @@ 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() 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_test.go b/serve_test.go index 64be7906..3fd2d3fd 100644 --- a/serve_test.go +++ b/serve_test.go @@ -433,7 +433,7 @@ func BenchmarkJawsMaintenanceSessions(b *testing.B) { b.Cleanup(jw.Close) jw.mu.Lock() for range 10_000 { - sess := jw.newSessionLocked(netip.Addr{}, false) + sess, _ := jw.newSessionLocked(netip.Addr{}, false) jw.registerSessionLocked(sess, false) } jw.mu.Unlock() diff --git a/session.go b/session.go index 377271f9..6805b521 100644 --- a/session.go +++ b/session.go @@ -397,15 +397,15 @@ func (jw *Jaws) GetSession(r *http.Request) (sess *Session) { // w nor r receives its live cookie. // // It returns nil without closing matching Sessions if r is nil, shutdown has -// begun, [Jaws.MaxSessions] is reached, or cookie publication fails; w may be -// nil. +// begun, [Jaws.MaxSessions] or [Jaws.MaxSessionsPerIP] is reached, or cookie +// publication fails; w may be nil. // // 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 { sessionIDs := getCookieSessionsIDs(r.Header, jw.CookieName) - sess = jw.newSession(w, r) + sess, _ = jw.newSession(w, r) if sess != nil && len(sessionIDs) > 0 { remoteIP := jw.clientIP(r) for _, sessionID := range sessionIDs { @@ -422,13 +422,13 @@ func (jw *Jaws) NewSession(w http.ResponseWriter, r *http.Request) (sess *Sessio 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) } @@ -440,11 +440,12 @@ func (jw *Jaws) newSession(w http.ResponseWriter, r *http.Request) (sess *Sessio } // newSessionLocked allocates a Session whose ID is absent from jw.sessions, or -// returns nil after shutdown begins or the Session limit is reached. +// 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 @@ -453,6 +454,10 @@ func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Sessio 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 @@ -472,6 +477,12 @@ 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 { + if jw.sessionBucketCounts == nil { + jw.sessionBucketCounts = make(map[netip.Addr]int) + } + jw.sessionBucketCounts[clientBucketKey(sess.remoteIP)]++ + } metrics := StatusMetricSessions if active { metrics |= StatusMetricActiveSessions @@ -490,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. @@ -506,6 +518,14 @@ 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.sessionBucketCounts != nil { + bucket := clientBucketKey(sess.remoteIP) + if count := jw.sessionBucketCounts[bucket]; count > 1 { + jw.sessionBucketCounts[bucket] = count - 1 + } else { + delete(jw.sessionBucketCounts, bucket) + } + } jw.markStatusDirty(StatusMetricSessions | StatusMetricActiveSessions) } } @@ -516,9 +536,16 @@ type sessioner struct { } func (sess sessioner) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if sess.jw.GetSession(r) == nil && sess.jw.newSession(w, r) == nil { - http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) - return + if sess.jw.GetSession(r) == nil { + 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) } @@ -526,8 +553,8 @@ func (sess sessioner) ServeHTTP(w http.ResponseWriter, r *http.Request) { // SessionMiddleware returns a session-creating [http.Handler]. // // Before invoking h, it creates a JaWS [Session] when the request has none. -// If creation fails or the Session becomes unavailable during cookie publication, -// it responds with HTTP 503 without invoking h. +// 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 10150228..b441f20b 100644 --- a/session_test.go +++ b/session_test.go @@ -592,6 +592,112 @@ func TestSession_MaxSessionsRefusesWithoutEviction(t *testing.T) { } } +func TestSession_MaxSessionsPerIP(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 2 + jw.MaxSessionsPerIP = 1 + serveDone := make(chan struct{}) + go func() { + jw.Serve() + close(serveDone) + }() + t.Cleanup(func() { + jw.Close() + <-serveDone + }) + 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_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 { @@ -1870,13 +1976,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 { From b381753d0dd238b8df9a478cb3afe9fb7088be0c Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 18:08:56 +0200 Subject: [PATCH 4/6] Document session rotation headroom and cover bucket decrement --- AI.md | 2 ++ jaws.go | 3 ++- session.go | 3 +++ session_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/AI.md b/AI.md index 2872af08..43cbd473 100644 --- a/AI.md +++ b/AI.md @@ -295,6 +295,8 @@ cleanup. Sessions per client address bucket. Set it below `MaxSessions` to reserve capacity for other buckets. A full bucket makes `SessionMiddleware` return HTTP 429 while existing Sessions remain usable; the global 503 takes precedence. +`NewSession` needs a free slot under each enabled cap to replace a Session; +rotation at a full bucket returns nil and keeps the old Session. Without this cap, one client making cookie-less requests can fill `MaxSessions`. Clients in one bucket share the cap, and clients using multiple addresses can still exhaust the global cap. Rate-limit Session-creating routes at the proxy. diff --git a/jaws.go b/jaws.go index b37b7ae1..336e16db 100644 --- a/jaws.go +++ b/jaws.go @@ -154,7 +154,8 @@ type Jaws struct { // which is the default. Existing Sessions remain usable at the limit; // [Jaws.SessionMiddleware] returns HTTP 429 for new ones unless the global // cap is also reached. The bucket uses the client IP selected by - // [Jaws.TrustForwardedHeaders]. + // [Jaws.TrustForwardedHeaders]. [Jaws.NewSession] needs a free slot to replace + // an existing Session in the bucket. MaxSessionsPerIP int // MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket. // diff --git a/session.go b/session.go index 6805b521..7ab3bc33 100644 --- a/session.go +++ b/session.go @@ -400,6 +400,9 @@ func (jw *Jaws) GetSession(r *http.Request) (sess *Session) { // 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) { diff --git a/session_test.go b/session_test.go index b441f20b..d41a55f0 100644 --- a/session_test.go +++ b/session_test.go @@ -664,6 +664,51 @@ func TestSession_MaxSessionsPerIP(t *testing.T) { } } +func TestSession_MaxSessionsPerIPRotationNeedsSlot(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + jw.MaxSessions = 3 + jw.MaxSessionsPerIP = 2 + serveDone := make(chan struct{}) + go func() { + jw.Serve() + close(serveDone) + }() + t.Cleanup(func() { + jw.Close() + <-serveDone + }) + 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 From 3a7a4b562206a01c8fadb170d525519295d7ab73 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 18:15:05 +0200 Subject: [PATCH 5/6] Simplify session bucket accounting --- jaws.go | 15 +++++++-------- session.go | 10 +++------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/jaws.go b/jaws.go index 336e16db..ba031393 100644 --- a/jaws.go +++ b/jaws.go @@ -148,14 +148,12 @@ type Jaws struct { MaxSessions int // MaxSessionsPerIP limits registered Sessions per client address bucket. // - // IPv4 and NAT64 addresses in 64:ff9b::/96 use their IPv4 address; other - // IPv6 addresses use a /64. All registered Sessions count, including active - // ones and those awaiting cleanup. 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 unless the global - // cap is also reached. The bucket uses the client IP selected by - // [Jaws.TrustForwardedHeaders]. [Jaws.NewSession] needs a free slot to replace - // an existing Session in the bucket. + // Buckets are those of [Jaws.MaxPendingRequestsPerIP] and use the client IP + // selected by [Jaws.TrustForwardedHeaders]. All registered Sessions count, + // including active ones and those awaiting cleanup. 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 unless + // the global cap is also reached. MaxSessionsPerIP int // MaxPendingRequestsPerIP limits unclaimed Requests per client address bucket. // @@ -229,6 +227,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/session.go b/session.go index 7ab3bc33..072f743c 100644 --- a/session.go +++ b/session.go @@ -481,9 +481,6 @@ func (jw *Jaws) newSessionLocked(remoteIP netip.Addr, secure bool) (sess *Sessio func (jw *Jaws) registerSessionLocked(sess *Session, active bool) { jw.sessions[sess.sessionID] = sess if jw.MaxSessionsPerIP > 0 { - if jw.sessionBucketCounts == nil { - jw.sessionBucketCounts = make(map[netip.Addr]int) - } jw.sessionBucketCounts[clientBucketKey(sess.remoteIP)]++ } metrics := StatusMetricSessions @@ -521,11 +518,10 @@ 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.sessionBucketCounts != nil { + if jw.MaxSessionsPerIP > 0 { bucket := clientBucketKey(sess.remoteIP) - if count := jw.sessionBucketCounts[bucket]; count > 1 { - jw.sessionBucketCounts[bucket] = count - 1 - } else { + jw.sessionBucketCounts[bucket]-- + if jw.sessionBucketCounts[bucket] <= 0 { delete(jw.sessionBucketCounts, bucket) } } From ad0e4fefa8158063896413eeb02df5161a0e5f23 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 18:21:40 +0200 Subject: [PATCH 6/6] Simplify session limit tests and documentation --- AI.md | 32 ++++++++++++++++---------------- jaws.go | 11 +++++------ session_test.go | 33 ++++++--------------------------- 3 files changed, 27 insertions(+), 49 deletions(-) diff --git a/AI.md b/AI.md index 43cbd473..4b09d46f 100644 --- a/AI.md +++ b/AI.md @@ -284,22 +284,22 @@ 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 removes expired Sessions every tenth pass. `MaxSessions` limits -registered Sessions; zero disables it. When admission hits the global limit, -`NewSession` returns nil and `SessionMiddleware` responds with HTTP 503 without -calling its handler. `AutoSession` may leave the Request without one; use -`SessionMiddleware` when a session is required. Expired Sessions count until -cleanup. - -`MaxSessionsPerIP` defaults to zero (disabled) and counts all registered -Sessions per client address bucket. Set it below `MaxSessions` to reserve -capacity for other buckets. A full bucket makes `SessionMiddleware` return HTTP -429 while existing Sessions remain usable; the global 503 takes precedence. -`NewSession` needs a free slot under each enabled cap to replace a Session; -rotation at a full bucket returns nil and keeps the old Session. -Without this cap, one client making cookie-less requests can fill `MaxSessions`. -Clients in one bucket share the cap, and clients using multiple addresses -can still exhaust the global cap. Rate-limit Session-creating routes at the proxy. +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 diff --git a/jaws.go b/jaws.go index ba031393..694d6712 100644 --- a/jaws.go +++ b/jaws.go @@ -148,12 +148,11 @@ type Jaws struct { MaxSessions int // MaxSessionsPerIP limits registered Sessions per client address bucket. // - // Buckets are those of [Jaws.MaxPendingRequestsPerIP] and use the client IP - // selected by [Jaws.TrustForwardedHeaders]. All registered Sessions count, - // including active ones and those awaiting cleanup. 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 unless - // the global cap is also reached. + // 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. // diff --git a/session_test.go b/session_test.go index d41a55f0..5c19a1df 100644 --- a/session_test.go +++ b/session_test.go @@ -542,15 +542,8 @@ func TestSession_MaxSessionsRefusesWithoutEviction(t *testing.T) { t.Fatal(err) } jw.MaxSessions = 1 - serveDone := make(chan struct{}) - go func() { - jw.Serve() - close(serveDone) - }() - t.Cleanup(func() { - jw.Close() - <-serveDone - }) + go jw.Serve() + t.Cleanup(jw.Close) waitForServeLoop(t, jw) firstRequest := httptest.NewRequest(http.MethodGet, "/first", nil) @@ -599,15 +592,8 @@ func TestSession_MaxSessionsPerIP(t *testing.T) { } jw.MaxSessions = 2 jw.MaxSessionsPerIP = 1 - serveDone := make(chan struct{}) - go func() { - jw.Serve() - close(serveDone) - }() - t.Cleanup(func() { - jw.Close() - <-serveDone - }) + go jw.Serve() + t.Cleanup(jw.Close) waitForServeLoop(t, jw) newRequest := func(ip string) *http.Request { @@ -671,15 +657,8 @@ func TestSession_MaxSessionsPerIPRotationNeedsSlot(t *testing.T) { } jw.MaxSessions = 3 jw.MaxSessionsPerIP = 2 - serveDone := make(chan struct{}) - go func() { - jw.Serve() - close(serveDone) - }() - t.Cleanup(func() { - jw.Close() - <-serveDone - }) + go jw.Serve() + t.Cleanup(jw.Close) waitForServeLoop(t, jw) newRequest := func() *http.Request {