Skip to content

Session hardening: secret in production, real client binding, per-request cookies, authenticate, CI - #4

Merged
eliasjpr merged 6 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e
Oct 22, 2026
Merged

eliasjpr merged 6 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e

Conversation

@eliasjpr

@eliasjpr eliasjpr commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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_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.

2. Session binding actually binds

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 no fingerprint was stored to compare with.

  • The fingerprint is 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. It now uses the connection's peer address, and the headers only behind a proxy declared with the new trust_proxy_headers option (default off).

3. CookieStore keeps its request cookies per request

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.

4. Store#authenticate

Reusing 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

  • The multi-node cache-clear spec slept a fixed 150 ms for a Pub/Sub message and failed on a loaded machine; it now polls with a bounded wait (eventually).
  • The release job 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 red release check on every PR.
  • The format check was commented out; the repository is formatted, so it is back on.

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 forged X-Forwarded-For is 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) and store_spec (authenticate).

crystal tool format --check is 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

  • Apps that deploy to production/staging on the default secret now fail at the first use of the encryptor. Set Session.configure { |c| c.secret = ... }, or opt out explicitly.
  • IP binding behind a reverse proxy needs trust_proxy_headers = true, otherwise every client appears as the proxy.
  • CookieStore#cookies is 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

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
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

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
@eliasjpr eliasjpr changed the title Refuse the default secret in production; make client binding real Session hardening: secret in production, real client binding, per-request cookies, authenticate, CI Oct 22, 2026
@eliasjpr
eliasjpr merged commit 64fbc19 into master Oct 22, 2026
6 checks passed
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.

2 participants