Repository navigation
Session hardening: secret in production, real client binding, per-request cookies, authenticate, CI - #4
Merged
Conversation
The default secret is a constant in the source, so a session cookie signed with it can be forged by anyone. require_secure_secret was off unless an app turned it on, so a deployment that forgot to set a secret ran on the public one with only a log warning. It now defaults to on when CRYSTAL_ENV is production or staging, and can still be turned off explicitly. Development and test keep the warning. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
validate_session_binding was a stub: it built a fingerprint and threw it away, so bind_to_ip and bind_to_user_agent never refused anything, and the session stored no fingerprint to compare with. The fingerprint is now saved in the session (Session::Base#client_fingerprint) on its first request and checked on every later one; a mismatch is answered with a fresh session. The client address used to come from X-Forwarded-For or X-Real-IP, which any client can send, so IP binding could be defeated by forging them. The peer address of the connection is used instead, and the headers only behind a proxy declared with trust_proxy_headers. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
Up to standards ✅🟢 Issues
|
The session lives in the request's cookies, and CookieStore kept them in one property shared by every request, so a concurrent request could read (or overwrite) another visitor's session cookie. They are now kept per fiber, like the current session, forgotten by release when the request ends, and pruned for fibers that have finished. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
Reusing the id a visitor had before logging in lets anyone who planted or observed it share the authenticated session (session fixation). authenticate yields the current session for the change and then regenerates its id, so a login cannot forget the second step. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
The multi-node cache-clear spec slept a fixed 150 ms for another node's Pub/Sub message to arrive, and failed on a loaded machine. It now polls for the result with a bounded wait (eventually), so the usual case is faster and a slow one is not a failure. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
The release job ran on pull requests too. It computes the next version, tags it and commits the bump to master, so on a pull request it either failed (the tag already exists, as on every PR since 1.0.32) or would have written to master from a PR run. It now needs a push event. The format check was commented out; the repository is formatted, so it is back on. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 0, item 12 of the plan (remaining security items) and the first Phase 1 step for this repository. Five commits plus a CI commit.
1. The default secret is refused in production and staging
The default secret is a constant in the source, so a session cookie signed with it can be forged by anyone.
require_secure_secretwas off unless an app turned it on, so a deployment that forgot to set a secret ran on the public one with only a log warning. It now defaults to on whenCRYSTAL_ENVisproductionorstaging, and can still be turned off explicitly. Development and test keep the warning.2. Session binding actually binds
validate_session_bindingwas a stub: it built a fingerprint and threw it away, sobind_to_ipandbind_to_user_agentnever refused anything, and no fingerprint was stored to compare with.Session::Base#client_fingerprint) on its first request and checked on every later one. A mismatch is answered with a fresh session.X-Forwarded-FororX-Real-IP, which any client can send, so IP binding could be defeated by forging them. It now uses the connection's peer address, and the headers only behind a proxy declared with the newtrust_proxy_headersoption (default off).3. CookieStore keeps its request cookies per request
The session lives in the request's cookies, and
CookieStorekept them in one property shared by every request, so a concurrent request could read (or overwrite) another visitor's session cookie. They are now kept per fiber, like the current session, forgotten byreleasewhen the request ends, and pruned for fibers that have finished.4.
Store#authenticateReusing the id a visitor had before logging in lets anyone who planted or observed it share the authenticated session (session fixation).
store.authenticate { |session| session.user_id = ... }applies the change and then regenerates the id, so a login cannot forget the second step.5. A flaky spec, and CI
eventually).releasejob ran on pull requests too. It computes the next version, tags it and commits the bump to master, so on a PR it failed (the tag exists, as on every PR since 1.0.32) or would have written to master from a PR run. It now needs a push event. This clears the redreleasecheck on every PR.Specs
configuration_spec(refused in production and staging, warned in development, opt-out, a real secret is accepted),client_binding_spec(a session stays with its client; another browser or address gets a fresh session; a forgedX-Forwarded-Foris ignored by default and believed behind a trusted proxy),cookie_store_isolation_spec(concurrent requests keep their own cookies; a session is read back only from its own cookies; finished fibers are pruned) andstore_spec(authenticate).crystal tool format --checkis clean on Crystal 1.22.0 and 1.19.1; 497 examples, 0 failures on 1.22.0 (the cluster spec failed once in earlier full runs before it was changed to poll; 4 of 4 runs passed after).Behaviour changes
production/stagingon the default secret now fail at the first use of the encryptor. SetSession.configure { |c| c.secret = ... }, or opt out explicitly.trust_proxy_headers = true, otherwise every client appears as the proxy.CookieStore#cookiesis per request; code that set it once at boot and relied on it persisting across requests must set it per request (the handler already does).🤖 Generated with Claude Code
https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f