Skip to content

fix(session): emit the session cookie before the response body is written - #509

Merged
FumingPower3925 merged 5 commits into
mainfrom
fix/session-cookie-before-write
Sep 5, 2026
Merged

fix(session): emit the session cookie before the response body is written#509
FumingPower3925 merged 5 commits into
mainfrom
fix/session-cookie-before-write

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What

middleware/session emitted the session Set-Cookie (or the session-id response header in extractor mode) only after c.Next() returned. celeris writes the response headers to the wire the moment a handler calls c.JSON/String/Blob (Context.Blob flips c.written and materialises respHeaders), so a header added after the handler returned was silently dropped.

Consequence: a login handler doing sess.Set("user", ...); sess.Save(); return c.JSON(200, ...) produced a 200 with the session id in the body and no Set-Cookie. The client never got a session and every authenticated request 401'd. Verified on v1.5.9 and v1.5.10, std engine locally and io_uring on the bench cluster. In probatorium's auth_session_ratelimit soak this showed as 96% 4xx, a re-login on every request and one persisted session per login (5 GB heap in 10 minutes). The existing tests (e.g. TestFreshSessionExplicitSaveEmitsCookie) passed only because their handlers return without writing a body.

Fix

Emit the cookie before the body can be written, keeping the post-chain path as a fallback:

  • The per-request Session now carries the middleware's resolved cookie template, cookie name, extractor mode and emittedID / clearSent / cookieDrop flags (installed before c.Next(), reset in returnToPool).
  • Every mutator funnels through markModified(), which emits the cookie at the first mutation: Set, Delete, Clear, SetIdleTimeout, a successful Save, and Regenerate (re-emits with the new id; the earlier cookie is replaced, never duplicated). Under SaveUnmodified a fresh session's cookie is emitted before the handler runs.
  • Destroy() emits the clearing cookie (Max-Age=0) immediately, replacing any session cookie emitted earlier in the request; no session cookie is emitted afterwards.
  • Header-extractor mode gets the same early emission via c.SetHeader(cookieName, sess.id).
  • Post-chain: persistence (sync and write-behind) is exactly as before; the cookie emission is now a no-op when the cookie for the current id is already on the response, so no-body handlers keep working and a response never carries two session Set-Cookie headers.
  • A mutation after the body was written cannot set a cookie: no panic, no undeliverable header queued, counted once per request in the new session.DroppedCookies() and, under -tags=validation, in validation.SessionCookieDrops (session_cookie_drops in the snapshot JSON) so probatorium's validator can observe it.
  • Secure is still upgraded under TLS/https (c.IsTLS()), as before.
  • Docs: doc.go has a new "When the cookie is emitted" section with the mutate-before-write caveat; Set, Save, Destroy, Regenerate, Config.SaveUnmodified and Config.WriteBehind comments updated.

Harness fix (first commit)

celeristest.ResponseRecorder retained the header slice Context.Blob passes to WriteResponse. That slice aliases the Context's inline header buffer, which keeps being mutated after the write: a post-write SetCookie overwrote the recorded content-type in place, so recorder-based tests saw a Set-Cookie that never reached the wire (probe on main: [[content-type text/plain] [content-length 2]] became [[set-cookie late=x] [content-length 2]]). The recorder now snapshots the headers; TestRecorderHeadersAreAWireSnapshot pins it. This is why no unit test could ever have caught the session bug.

Fail-first evidence (against unfixed session.go, with the recorder fix applied)

Std-engine wire round trip:

--- FAIL: TestLoginRoundTripStdEngine (5.06s)
    engine_roundtrip_test.go:201: POST /login: got 0 Set-Cookie headers [], want exactly 1 (body sid=7c49a64003a363f4360d6ff9437419677cf1c9df68e3a5a960b1b01834797bf8)

Recorder-level tests (every body-writing shape fails; the three no-body shapes pass on main, as expected):

--- FAIL: TestSetThenBodyEmitsCookieOnWire (0.00s)
    cookie_emit_test.go:93: got 0 Set-Cookie headers [], want exactly 1
--- FAIL: TestLoginShapeSetSaveBodyEmitsCookie (0.00s)
    --- FAIL: TestLoginShapeSetSaveBodyEmitsCookie/sync (0.00s)
    --- FAIL: TestLoginShapeSetSaveBodyEmitsCookie/write-behind (0.00s)
--- FAIL: TestSaveUnmodifiedFreshWithBodyEmitsCookie (0.00s)
    cookie_emit_test.go:160: got 0 Set-Cookie headers [], want exactly 1
--- FAIL: TestDestroyThenBodyEmitsClearingCookie (0.00s)
    cookie_emit_test.go:183: got 0 Set-Cookie headers [], want exactly 1 clearing cookie
--- FAIL: TestRegenerateThenBodyEmitsNewID (0.00s)
    --- FAIL: TestRegenerateThenBodyEmitsNewID/set-then-regenerate (0.00s)
    --- FAIL: TestRegenerateThenBodyEmitsNewID/regenerate-then-set (0.00s)
    --- FAIL: TestRegenerateThenBodyEmitsNewID/reset (0.00s)
--- FAIL: TestExactlyOneSessionCookiePerResponse (0.01s)
    --- FAIL: .../set-nocontent, set-json, set-save-json, set-delete-clear-set-json, set-regenerate-json,
              saveunmodified-untouched-json, saveunmodified-set-json, saveunmodified-regenerate-json
        cookie_emit_test.go:316: got 0 Set-Cookie headers [], want exactly 1
--- FAIL: TestHeaderExtractorEmitsIDBeforeBody (0.00s)
    cookie_emit_test.go:345: celeris_session response header on the wire: [], want [dd7fe4cb...07e35c]
--- FAIL: TestEarlyCookieSecureUnderHTTPS (0.00s)
    cookie_emit_test.go:392: Set-Cookie under https: [], want one cookie with Secure

TestMutationAfterBodyWrittenDropsCookieOnce (test f) does not compile against main (DroppedCookies undefined); it pins no-panic / no header / exactly one count / unchanged persistence / clean pool reuse.

Tests

New: cookie_emit_test.go (a–e, g, header-extractor mode, https Secure, read-only stays cookieless), cookie_drop_test.go (f), cookie_drop_validation_test.go (validation counter, -tags=validation), engine_roundtrip_test.go (std-engine login → cookie → authenticated GET → logout → 401 over real TCP with a cookie jar, runs on every OS), engine_roundtrip_linux_test.go (h: same round trip on io_uring and epoll, skips when the engine is unavailable), celeristest/recorder_snapshot_test.go.

After the fix (macOS, arm64):

gofmt -l                                                  clean
go vet ./...                                              clean
golangci-lint run ./middleware/session/... ./celeristest/... ./validation/...   0 issues
go test -race ./middleware/session/ ./celeristest/ ./validation/                ok
go test -race -tags=validation ./middleware/session/ ./validation/              ok
go test -short ./middleware/...                                                 ok (all packages)
go test -short .                                                                ok

The linux native-engine round trip (TestLoginRoundTripNativeEngines) has not been run locally (darwin); CI's linux job covers it.

Issue

No open issue tracks this (searched Set-Cookie and session cookie, open and closed, in goceleris/celeris and goceleris/probatorium). Related: #487 (lazy session creation), whose tests pass only for no-body handlers. Please link or open the tracking issue; the PR body can then carry Fixes #<n>.

Fixes #507.

Follow-up finding fixed in this PR

The recorder fix exposed the same defect in middleware/otel: the traceparent response header was injected after c.Next() and never reached the wire when the handler wrote a body (TestResponseHeaderPropagation started failing on CI once the recorder observed the wire). Commit 0854272 injects the trace context before the handler runs.

ResponseRecorder retained the header slice Context.Blob passed to
WriteResponse. That slice aliases the Context's inline header buffer,
which keeps being mutated after the write: a SetCookie issued after the
body was written (the shape of a post-chain middleware) overwrote the
recorded content-type in place, so tests saw a Set-Cookie that never
reached the wire and lost one that did. Copy the headers so the recorder
is a faithful snapshot of what hit the wire.
…tten

The middleware emitted Set-Cookie (or the session-id header in
extractor mode) only after c.Next() returned. celeris materialises the
response headers on the wire the moment a handler calls
c.JSON/String/Blob, so any header added afterwards is silently dropped:
a login handler doing sess.Set + sess.Save + c.JSON(200, ...) returned
the session id in the body with no Set-Cookie, the client never got a
session and every authenticated request 401'd. probatorium's
auth_session_ratelimit soak showed this as 96% 4xx, a re-login on every
request and one persisted session per login (5 GB heap in 10 minutes).
The existing tests passed only because their handlers wrote no body.

Emit the cookie at the FIRST of: any mutator (Set, Delete, Clear,
SetIdleTimeout), Save, Regenerate (re-emits with the new id, replacing
the old one), Destroy (clearing cookie), or, under SaveUnmodified,
before the handler runs for a fresh session. The per-request Session
carries the middleware's cookie template plus emitted-id/clearing flags
(reset on pool return); exactly one session Set-Cookie ever ends up on a
response. The post-chain emission stays as the fallback for no-body
handlers. Persistence, write-behind and SaveUnmodified semantics are
unchanged.

A mutation after the body was written cannot set a cookie any more: it
no longer panics or queues an undeliverable header, and is counted once
per request in session.DroppedCookies() and, under -tags=validation, in
the new validation.SessionCookieDrops counter so the soak validator can
see it. Package and method docs now state when the cookie is emitted and
the mutate-before-write caveat.

Tests: recorder-level coverage of every shape (Set+body, Set+Save+body
with and without write-behind, SaveUnmodified fresh+body, Destroy+body,
Regenerate/Reset+body, header-extractor mode, https Secure, late
mutation), an exactly-one-Set-Cookie table over body and no-body
shapes, a std-engine wire round trip (login -> cookie -> authenticated
GET -> logout) that runs everywhere, and its io_uring/epoll variant on
linux mirroring the probatorium refapp.
… on a failed save

DroppedCookies over-reported: a loaded session mutated after the body was written counted as a drop although the client already holds that exact cookie and the data is persisted under the same id (only the Max-Age refresh is lost). Record the id the request arrived with (presentedID) and count a drop only when the undelivered header would change what the client holds: a fresh or regenerated id, or the clearing cookie.

Early emission also changed the failed-save contract: store.Set / EncodeJSON failures returned through ErrorHandler with a Set-Cookie for an id the store never received (main sent none). Retract that cookie (or the session-id header in extractor mode) before ErrorHandler so the failed response carries no session cookie, matching main; a loaded session's own id stays since it is still valid. Write-behind cannot fail synchronously and keeps the cookie, by design. Documented in doc.go, DroppedCookies, and the validation counter docs; pinned by tests for both findings on the sync, encode, header-extractor, and write-behind paths.
The traceparent response header was injected after c.Next(); celeris
materialises response headers on the wire as soon as the handler writes a
body, so the header never reached the client (the same defect class as the
session cookie in #507). The recorder fix in this branch made the existing
TestResponseHeaderPropagation tests observe the wire and fail; injecting
before the handler runs makes them pass and the header real.
@FumingPower3925
FumingPower3925 merged commit 1ff8feb into main Sep 5, 2026
10 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/session-cookie-before-write branch September 5, 2026 23:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

session: Set-Cookie is emitted only after c.Next(), so it is dropped whenever the handler already wrote the body — login never issues a session cookie

1 participant