Skip to content

security: session & cookie hardening (fixation, TTL, MFA rate limit, impersonation auth)#1518

Open
dgilperez wants to merge 9 commits into
we-promise:mainfrom
dgilperez:security/pr2-session
Open

security: session & cookie hardening (fixation, TTL, MFA rate limit, impersonation auth)#1518
dgilperez wants to merge 9 commits into
we-promise:mainfrom
dgilperez:security/pr2-session

Conversation

@dgilperez
Copy link
Copy Markdown
Contributor

@dgilperez dgilperez commented Apr 19, 2026

Summary

Session lifecycle, cookie security attributes, MFA flow rate limiting, and impersonation authorization.

Part of the security hardening originally bundled in #1104 (closed), now split by functional area per @jjmata's feedback. Companion PRs: #1516 #1517 #1519 #1520 #1521.

Findings addressed (7)

ID Severity CWE Fix
FIX-01 HIGH CWE-384 Session fixation on non-MFA login — reset_session added in sessions_controller.rb (all login paths incl. OIDC)
FIX-13 MED CWE-613 Password reset doesn't invalidate sessions — all sessions for user revoked on reset in password_resets_controller.rb
F-04 MED CWE-613 Sessions never expire — SESSION_ABSOLUTE_TTL = 30.days + SESSION_IDLE_TTL = 24.hours enforced in concerns/authentication.rb
PT-002 / C1 MED Cookie flags hardened in authentication.rb: secure: Rails.env.production?, same_site: :lax
PT-003 MED CWE-307 MFA rate limit + verify-flow TTL + reset_session on verify in mfa_controller.rb
PT-005 MED CWE-862 Impersonation complete authorized to impersonator OR impersonated (with nil-guard)

Migration notes

Existing long-lived sessions will be invalidated on first request after deploy (expected — was the goal).

Tests

  • New coverage in sessions_controller_test.rb, mfa_controller_test.rb, password_resets_controller_test.rb, impersonation_sessions_controller_test.rb.
  • Full suite: 3232 runs, 0 failures.
  • bin/rubocop clean.

Out of scope

Related

Summary by CodeRabbit

  • Bug Fixes

    • Sessions now expire after 30 days (absolute) or 24 hours idle.
    • MFA verification enforces a 5-minute TTL and locks out after 5 failed attempts.
    • Password resets now invalidate all existing sessions.
    • Stronger authorization for completing impersonation sessions.
  • Improvements

    • Session handling hardened to prevent session fixation while preserving pending invitation tokens.
  • Tests

    • New tests covering MFA throttling/expiration, session TTLs, impersonation auth, password-reset session invalidation.

Call reset_session before create_session_for on successful non-MFA login
(both password and OIDC paths). This rotates the session ID and prevents
session fixation attacks where an attacker pre-seeds a victim's session
cookie before they authenticate.

Source: pentest-2026-03-02 commit 0b04682.
After a user updates their password via the reset flow, destroy all
their existing Session records so an attacker who has stolen a session
cookie cannot continue to access the account once the legitimate user
has regained control by resetting the password.

Source: pentest-2026-03-02 commit 0b04682.
Set secure: Rails.env.production? and same_site: :lax on the signed
session_token cookie in addition to the existing httponly: true.

- secure: only transmit the cookie over HTTPS in production, preventing
  interception on the wire.
- same_site: :lax: mitigates CSRF attacks by not sending the session
  cookie on cross-site subrequests while still allowing top-level nav.

Matches finding PT-002 (pentest 2026-02-24) and C1 from issue #1087.

Source: pentest-2026-03-02 commits d06290a + 3e6ab5b.
Harden the MFA verification flow after the first factor succeeds:

- Track mfa_attempts in the session and redirect back to new_session_path
  after 5 wrong codes, forcing re-authentication of the first factor.
- Track mfa_started_at when entering MFA flow; expire the pending MFA
  session after 5 minutes to bound the attack window.
- Call reset_session on successful verify_code before create_session_for
  to prevent session fixation on the MFA login path (parallel to FIX-01
  on the non-MFA path).

Initialize mfa_started_at and mfa_attempts in the sessions#create MFA
branch so the TTL and counter are always well-defined.

Source: pentest-2026-03-02 commit d06290a.
Before PT-005, any authenticated user could end any in-progress
impersonation session by calling the complete action with an arbitrary
session id, because set_impersonation_session scoped lookup was the
only authorization.

Require Current.true_user to be either the impersonator or the
impersonated party of the target session; otherwise raise a 404 via
raise_unauthorized! in line with the other actions.

Source: pentest-2026-03-02 commit d06290a + 43a04d8 (adjusted to
allow both participants, matching real UX).
Sessions previously never expired server-side (CWE-613). Add
server-side enforcement in find_session_by_cookie:

- SESSION_ABSOLUTE_TTL (30 days): sessions older than this by
  created_at are destroyed and the cookie is cleared on the next
  request regardless of activity.
- SESSION_IDLE_TTL (24 hours): sessions whose updated_at is older
  than this are destroyed and the cookie is cleared.
- On every authenticated request we Session#touch to refresh the
  idle timer so active users stay logged in.

Source: pentest-2026-03-02 commit 14927f7.
set_impersonation_session already scopes by Current.true_user's own
impersonator/impersonated associations, so the record is nil for
unrelated callers. Without this guard, the participant check crashed
with NoMethodError for nil:NilClass instead of returning 404.

Raise not-found first when the record is missing, then apply the
participant check as defense-in-depth.
@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label Apr 19, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff6898ea-0ed6-4fb4-aef3-6cdc548fe9b1

📥 Commits

Reviewing files that changed from the base of the PR and between 2e7b76c and ad61c1f.

📒 Files selected for processing (1)
  • test/controllers/password_resets_controller_test.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/controllers/password_resets_controller_test.rb

📝 Walkthrough

Walkthrough

Enforces session TTLs (30-day absolute, 24-hour idle), validates/deletes missing session cookies, prevents session fixation by resetting sessions while preserving pending-invitation tokens, adds MFA attempt/TTL safeguards, invalidates user sessions on password reset, and tightens impersonation completion authorization.

Changes

Cohort / File(s) Summary
Session Lifecycle & Cookie Handling
app/controllers/concerns/authentication.rb
Validate signed :session_token cookie, return nil and delete cookie when absent or DB row missing; enforce absolute TTL (30 days) and idle TTL (24 hours) by destroying expired Session rows and deleting cookie; refresh idle timer via throttled session.touch; set signed permanent cookie with secure (prod) and same_site: :lax in create_session_for.
Session Fixation & Login Flows
app/controllers/sessions_controller.rb
Call reset_session_preserving_pending_invitation before creating authenticated sessions in normal and OpenID Connect flows to prevent session fixation while preserving session[:pending_invitation_token]; initialize MFA state (session[:mfa_started_at] ISO8601 and session[:mfa_attempts]=0) when OTP required.
MFA Enforcement & Flow
app/controllers/mfa_controller.rb
Increment and track session[:mfa_attempts], rate-limit after 5 failed attempts (clear mfa keys + redirect), enforce 5-minute MFA handoff TTL (clear keys + redirect on expiry), and on successful OTP call reset_session_preserving_pending_invitation before completing auth.
Password Reset Session Invalidation
app/controllers/password_resets_controller.rb
On successful password update, destroy all existing @user.sessions before redirecting to login.
Impersonation Authorization
app/controllers/impersonation_sessions_controller.rb
Guard complete action: reject when impersonation session missing or caller is neither impersonator nor impersonated; after completion clear Current.session.active_impersonator_session if it still referenced the completed session.
Tests — Sessions & TTLs
test/controllers/sessions_controller_test.rb
Added tests for session fixation behaviors (reset/session id change, pending-invitation preservation) and server-enforced absolute/idle TTLs (backdate created_at/updated_at and assert redirect + Session destruction).
Tests — MFA
test/controllers/mfa_controller_test.rb
Added tests for MFA rate-limiting (6 invalid codes → redirect) and MFA handoff expiration (advance time >5 minutes → redirect and clear MFA state).
Tests — Password Reset & Impersonation
test/controllers/password_resets_controller_test.rb, test/controllers/impersonation_sessions_controller_test.rb
Password reset test asserts all user sessions destroyed after reset. Impersonation test asserts unauthorized completion returns :not_found and leaves impersonation session in_progress.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant SessionsController
    participant AuthenticationConcern
    participant Session
    participant MfaController

    User->>SessionsController: POST /sessions (credentials)
    SessionsController->>AuthenticationConcern: reset_session_preserving_pending_invitation
    alt User requires MFA
        SessionsController->>SessionsController: set session[:mfa_started_at], session[:mfa_attempts]=0
        SessionsController-->>User: Redirect to /verify_mfa
        User->>MfaController: POST /verify_mfa (OTP)
        MfaController->>MfaController: increment session[:mfa_attempts]
        alt Attempts > 5
            MfaController->>MfaController: clear mfa keys
            MfaController-->>User: Redirect to /sessions (too many attempts)
        else TTL expired (>5m)
            MfaController->>MfaController: clear mfa keys
            MfaController-->>User: Redirect to /sessions (session expired)
        else Valid OTP
            MfaController->>AuthenticationConcern: reset_session_preserving_pending_invitation
            AuthenticationConcern->>Session: create Session, set signed cookie (secure?, SameSite=Lax)
            MfaController-->>User: Redirect to home (authenticated)
        end
    else No MFA required
        SessionsController->>AuthenticationConcern: create_session_for
        AuthenticationConcern->>Session: create Session, set signed cookie
        SessionsController-->>User: Redirect to home (authenticated)
    end
Loading
sequenceDiagram
    actor Client
    participant App
    participant AuthenticationConcern
    participant Session

    Client->>App: GET /protected
    App->>AuthenticationConcern: find_session_by_cookie
    AuthenticationConcern->>Session: query by token
    alt no cookie or no DB row
        AuthenticationConcern-->>App: nil (delete cookie)
        App-->>Client: Redirect to /sessions
    else created_at > 30 days
        AuthenticationConcern->>Session: destroy
        AuthenticationConcern-->>App: nil (delete cookie)
        App-->>Client: Redirect to /sessions
    else updated_at > 24 hours
        AuthenticationConcern->>Session: destroy
        AuthenticationConcern-->>App: nil (delete cookie)
        App-->>Client: Redirect to /sessions
    else active
        AuthenticationConcern->>Session: touch (if updated_at > 1 minute)
        AuthenticationConcern-->>App: return session
        App-->>Client: serve request (authenticated)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • luckyPipewrench

Poem

🐰 I hop to guard each session thread,

I nudge the clock when idle's fled,
Five wrong tries, then gates close tight,
Old keys removed from burrowed night,
Invitations kept safe till light.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main security-focused changes: session fixation prevention, TTL enforcement, MFA rate limiting, and impersonation authorization tightening.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label Apr 19, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8c6520134

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread app/controllers/sessions_controller.rb Outdated
session[:mfa_user_id] = user.id
redirect_to verify_mfa_path
else
reset_session # Prevent session fixation (FIX-01)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve OIDC logout context when rotating session

openid_connect stores session[:id_token_hint] and session[:sso_login_provider] immediately before this branch, but reset_session clears those values. SessionsController#destroy depends on those keys to build the IdP end-session URL, so OIDC users who log in through this path lose federated logout and only get local logout. Stash and restore the logout keys across session rotation.

Useful? React with 👍 / 👎.

Comment thread app/controllers/sessions_controller.rb Outdated
Comment on lines 71 to 73
reset_session # Prevent session fixation (FIX-01)
@session = create_session_for(user)
flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep invitation token across login session reset

accept_pending_invitation_for reads session[:pending_invitation_token], which is set during SessionsController#new. Rotating the Rails session here clears that token before accept_pending_invitation_for(user) runs, so invited users can authenticate but fail to auto-join the household. Preserve this token across reset_session (same pattern applies to other login branches that reset before acceptance).

Useful? React with 👍 / 👎.

Comment thread app/controllers/mfa_controller.rb Outdated
end

# TTL: MFA flow expires after 5 minutes (PT-003)
if session[:mfa_started_at].present? && Time.current - Time.parse(session[:mfa_started_at].to_s) > 5.minutes
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce MFA TTL when start timestamp is absent

The timeout check only executes when session[:mfa_started_at] is present. In this commit, only the local password login path initializes mfa_started_at; OIDC MFA entry points still set only mfa_user_id, so this condition is skipped and the 5-minute MFA expiry is not enforced for those users. Initialize mfa_started_at in every MFA-start flow or treat a missing timestamp as expired.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/controllers/sessions_controller.rb (1)

70-75: ⚠️ Potential issue | 🟠 Major

reset_session clears pending invitation data before it can be accepted.

accept_pending_invitation_for reads session[:pending_invitation_token], which is populated by store_pending_invitation_if_valid in the login flow. However, reset_session (lines 71, 189 in sessions_controller; line 55 in mfa_controller) clears the entire session before accept_pending_invitation_for is called, so the invitation token is lost and invitations never auto-link to users.

The OIDC path in oidc_accounts_controller avoids this because it does not call reset_session, allowing invitations to work there.

Fix: Capture the token before reset_session:

token = session[:pending_invitation_token]
reset_session
session[:pending_invitation_token] = token

Or call accept_pending_invitation_for before reset_session, then delete the token after.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/sessions_controller.rb` around lines 70 - 75, The session
reset is clearing session[:pending_invitation_token] before
accept_pending_invitation_for can read it; update the login flow in
sessions_controller (and the analogous place in mfa_controller) to preserve the
pending invitation token across reset_session — e.g. read
session[:pending_invitation_token] into a local variable, call reset_session,
then restore it to session[:pending_invitation_token] before calling
accept_pending_invitation_for (alternatively call
accept_pending_invitation_for(user) before reset_session and then remove the
token), ensuring store_pending_invitation_if_valid remains the source of the
token.
🧹 Nitpick comments (3)
app/controllers/concerns/authentication.rb (1)

41-59: Write amplification: session.touch on every authenticated request.

session.touch issues an UPDATE against the sessions table on every request from every authenticated user. On a busy deployment this becomes a hot write path and also produces high replica lag / WAL churn. Consider only refreshing the idle timer when it is actually close to expiring (e.g., once per minute).

♻️ Proposed throttled touch
-      # Touch to refresh idle timer on each request
-      session.touch
-      session
+      # Refresh idle timer at most once per minute to avoid per-request writes
+      if session.updated_at < now - 1.minute
+        session.touch
+      end
+      session
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/concerns/authentication.rb` around lines 41 - 59, The
hot-write comes from always calling session.touch; instead introduce a throttle
so we only touch when the session's updated_at is sufficiently old (e.g., define
SESSION_TOUCH_THROTTLE = 1.minute) and replace the unconditional session.touch
with a conditional that calls session.touch only if session.updated_at < now -
SESSION_TOUCH_THROTTLE (keep the existing absolute/idle TTL checks using
SESSION_ABSOLUTE_TTL and SESSION_IDLE_TTL), ensuring the symbol session.touch is
preserved and tests/behaviour around idle expiry are updated to account for the
throttled refresh.
app/controllers/mfa_controller.rb (1)

43-55: Two small refinements in the verify_code flow.

  1. Time.parse(session[:mfa_started_at].to_s) will raise ArgumentError if the stored value is ever malformed (tampered cookie, legacy format, partial write). Since reset_session at login sets it to a known iso8601 value, prefer a non-raising parse and treat any failure as "expired" so the user is safely redirected instead of hitting a 500.

  2. Lines 52–54 delete the three MFA session keys individually and then line 55 calls reset_session, which wipes the entire Rails session. The three explicit deletes are dead work — keep reset_session and drop the deletes (or drop reset_session and keep only the targeted deletes; the former is safer against fixation and is what the comment promises).

♻️ Proposed fix
-    # TTL: MFA flow expires after 5 minutes (PT-003)
-    if session[:mfa_started_at].present? && Time.current - Time.parse(session[:mfa_started_at].to_s) > 5.minutes
+    # TTL: MFA flow expires after 5 minutes (PT-003)
+    started_at = Time.zone.parse(session[:mfa_started_at].to_s) rescue nil
+    if session[:mfa_started_at].present? && (started_at.nil? || Time.current - started_at > 5.minutes)
       session.delete(:mfa_user_id)
       session.delete(:mfa_attempts)
       session.delete(:mfa_started_at)
       redirect_to new_session_path, alert: t(".session_expired", default: "MFA session expired. Please sign in again.")
       return
     end

     if `@user`&.verify_otp?(params[:code])
-      session.delete(:mfa_user_id)
-      session.delete(:mfa_attempts)
-      session.delete(:mfa_started_at)
       reset_session # Prevent session fixation (PT-003)
       `@session` = create_session_for(`@user`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/mfa_controller.rb` around lines 43 - 55, Replace the brittle
Time.parse(session[:mfa_started_at].to_s) call with a non-raising parse (e.g.
Time.iso8601(session[:mfa_started_at].to_s) or Time.zone.parse wrapped in rescue
-> nil) and treat a failed/nil parse as expired so the flow redirects instead of
raising; also remove the three explicit
session.delete(:mfa_user_id)/(:mfa_attempts)/(:mfa_started_at) lines when you
call reset_session (keep reset_session for fixation protection) so you don't
perform dead work after reset_session in the verify flow that uses
session[:mfa_started_at] and `@user.verify_otp`?(params[:code]).
test/controllers/mfa_controller_test.rb (1)

138-151: Strengthen TTL assertion to rule out a false-positive pass.

A valid TOTP is submitted here, so the only reason this redirect fires is that the TTL branch clears mfa_user_id before verification. If that branch regressed (say, was placed after verify_otp?), a successful authentication could also end on new_session_path via other flows and still satisfy assert_redirected_to. Add a negative assertion to pin the intent.

♻️ Proposed stronger assertion
     travel_to(6.minutes.from_now) do
       post verify_mfa_path, params: { code: ROTP::TOTP.new(other_user.otp_secret).now }
       assert_redirected_to new_session_path
+      assert_not Session.exists?(user_id: other_user.id), "No auth session should be created after TTL expiry"
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/controllers/mfa_controller_test.rb` around lines 138 - 151, The test
"MFA session expires after TTL" currently only asserts the redirect which can be
satisfied by multiple flows; after the post to verify_mfa_path add a negative
assertion that the MFA session was cleared (e.g. assert_nil
session[:mfa_user_id] or assert_not session.key?(:mfa_user_id)) to ensure the
TTL branch actually removed mfa_user_id for other_user rather than another
successful auth path satisfying assert_redirected_to new_session_path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/controllers/concerns/authentication.rb`:
- Around line 38-39: When a signed :session_token cookie exists but
Session.find_by(id: cookie_value) returns nil, delete the stale cookie instead
of just returning nil; in the method where cookie_value is assigned and you call
Session.find_by(id: cookie_value), call the cookies.delete(:session_token) (or
equivalent delete for signed cookies) before returning nil so the browser no
longer resends the stale token on subsequent requests.

In `@app/controllers/impersonation_sessions_controller.rb`:
- Around line 35-43: The complete action marks the ImpersonationSession complete
but doesn't clear the browser's active impersonation reference; update the
complete method (in impersonation_sessions_controller#complete) to also clear
Current.session.active_impersonator_session (or call the same helper used by
leave) after `@impersonation_session.complete`! so the Current.session no longer
references the finished `@impersonation_session`; keep the existing authorization
checks and redirect/notice behavior.

In `@test/controllers/sessions_controller_test.rb`:
- Around line 684-691: The test currently can pass spuriously because the
pre-auth session may be empty; seed a known sentinel into the pre-auth session
(e.g. set session[:pre_auth_sentinel] = "seed" before calling sign_in in the
"session fixation: new session created on login (reset_session)" test), then
perform sign_in `@user` and assert that the sentinel is gone (assert_nil
session[:pre_auth_sentinel]) and that session.id changed if desired; update the
test body around get new_session_url, old_session_id = session.id, sign_in `@user`
to set the sentinel before sign_in and replace the weak assert_not_equal check
with the stronger sentinel disappearance assertion (and keep the session.id
inequality check if you still want to verify id rotation).

---

Outside diff comments:
In `@app/controllers/sessions_controller.rb`:
- Around line 70-75: The session reset is clearing
session[:pending_invitation_token] before accept_pending_invitation_for can read
it; update the login flow in sessions_controller (and the analogous place in
mfa_controller) to preserve the pending invitation token across reset_session —
e.g. read session[:pending_invitation_token] into a local variable, call
reset_session, then restore it to session[:pending_invitation_token] before
calling accept_pending_invitation_for (alternatively call
accept_pending_invitation_for(user) before reset_session and then remove the
token), ensuring store_pending_invitation_if_valid remains the source of the
token.

---

Nitpick comments:
In `@app/controllers/concerns/authentication.rb`:
- Around line 41-59: The hot-write comes from always calling session.touch;
instead introduce a throttle so we only touch when the session's updated_at is
sufficiently old (e.g., define SESSION_TOUCH_THROTTLE = 1.minute) and replace
the unconditional session.touch with a conditional that calls session.touch only
if session.updated_at < now - SESSION_TOUCH_THROTTLE (keep the existing
absolute/idle TTL checks using SESSION_ABSOLUTE_TTL and SESSION_IDLE_TTL),
ensuring the symbol session.touch is preserved and tests/behaviour around idle
expiry are updated to account for the throttled refresh.

In `@app/controllers/mfa_controller.rb`:
- Around line 43-55: Replace the brittle
Time.parse(session[:mfa_started_at].to_s) call with a non-raising parse (e.g.
Time.iso8601(session[:mfa_started_at].to_s) or Time.zone.parse wrapped in rescue
-> nil) and treat a failed/nil parse as expired so the flow redirects instead of
raising; also remove the three explicit
session.delete(:mfa_user_id)/(:mfa_attempts)/(:mfa_started_at) lines when you
call reset_session (keep reset_session for fixation protection) so you don't
perform dead work after reset_session in the verify flow that uses
session[:mfa_started_at] and `@user.verify_otp`?(params[:code]).

In `@test/controllers/mfa_controller_test.rb`:
- Around line 138-151: The test "MFA session expires after TTL" currently only
asserts the redirect which can be satisfied by multiple flows; after the post to
verify_mfa_path add a negative assertion that the MFA session was cleared (e.g.
assert_nil session[:mfa_user_id] or assert_not session.key?(:mfa_user_id)) to
ensure the TTL branch actually removed mfa_user_id for other_user rather than
another successful auth path satisfying assert_redirected_to new_session_path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4eb289a7-920a-4e70-a549-815ef70daaf2

📥 Commits

Reviewing files that changed from the base of the PR and between b32e9db and d8c6520.

📒 Files selected for processing (9)
  • app/controllers/concerns/authentication.rb
  • app/controllers/impersonation_sessions_controller.rb
  • app/controllers/mfa_controller.rb
  • app/controllers/password_resets_controller.rb
  • app/controllers/sessions_controller.rb
  • test/controllers/impersonation_sessions_controller_test.rb
  • test/controllers/mfa_controller_test.rb
  • test/controllers/password_resets_controller_test.rb
  • test/controllers/sessions_controller_test.rb

Comment thread app/controllers/concerns/authentication.rb Outdated
Comment thread app/controllers/impersonation_sessions_controller.rb
Comment thread test/controllers/sessions_controller_test.rb
Major:
- reset_session clearing pending_invitation_token: added
  reset_session_preserving_pending_invitation helper in Authentication concern
  and use it at all 3 privilege-change sites (two in sessions_controller, one
  in mfa_controller). Previously the token was wiped before
  accept_pending_invitation_for could read it, breaking invitation auto-link
  for any flow that went through local/super-admin login or MFA verify.
- Stale session cookie: when cookies.signed[:session_token] references a
  deleted Session row, delete the cookie so the browser stops resending it.
- Impersonation complete: clear Current.session.active_impersonator_session
  when it still points at the now-completed ImpersonationSession (previously
  the UI kept the banner/true_user mapping until page reload).

Refinements:
- session.touch throttled to max once per minute (SESSION_TOUCH_THROTTLE) to
  avoid a per-request write against the sessions table. Idle-TTL granularity
  is unaffected in practice.
- mfa_controller verify_code: non-raising parse of :mfa_started_at (malformed
  values now redirect instead of 500), and drop the three redundant
  session.delete calls in the success path — reset_session already wipes them.

Tests:
- session fixation test seeds a pre-auth sentinel and asserts it was wiped,
  replacing the tautological "session.id changed" check.
- new test: pending_invitation_token preserved across reset_session (goes
  through the real store_pending_invitation_if_valid path with a fixture).
- MFA TTL test: negative assertion that no auth Session row is created when
  the OTP arrives past the TTL window.
@dgilperez
Copy link
Copy Markdown
Contributor Author

Thanks — addressed all seven points.

Major — reset_session clears pending invitation (sessions_controller.rb:70, mfa_controller.rb:55): Good catch, real bug. Added reset_session_preserving_pending_invitation in the Authentication concern that captures the token, resets, and restores it. All three privilege-change sites (two in sessions_controller, one in mfa_controller) now use it. New integration test in sessions_controller_test.rb goes through the real store_pending_invitation_if_valid path with a fixture invitation and asserts the token is still in the session after login — which can only be true if the helper restored it after reset_session.

Stale session cookie (authentication.rb:38): Agreed. Now calls cookies.delete(:session_token) when Session.find_by(id:) returns nil so the browser stops resending a dead token.

Impersonation complete keeps dangling pointer (impersonation_sessions_controller.rb:35): Added a post-complete! branch that clears Current.session.active_impersonator_session when it still references this record. Uses active_impersonator_session_id comparison so we don't no-op-update a different live impersonation.

session.touch write amplification (authentication.rb:41-59): Added SESSION_TOUCH_THROTTLE = 1.minute. session.touch now only runs when updated_at < now - SESSION_TOUCH_THROTTLE. Idle-TTL precision is unchanged in practice (24h window); WAL / replica lag on busy deploys goes from 1 write per request to 1 per minute per active session.

MFA verify brittleness + dead deletes (mfa_controller.rb:43-55):

  • Replaced Time.parse(...) with Time.zone.parse(...) rescue nil and treat failure as expired (redirects instead of 500).
  • Dropped the three session.delete(:mfa_*) lines from the success path — reset_session wipes them anyway. Kept them in the TTL-expired path because no reset_session runs there (we just redirect).
  • Success path now also preserves pending_invitation_token via the new helper.

Session fixation test tautological (sessions_controller_test.rb:684): Now seeds session[:pre_auth_sentinel] = "seed" before sign-in and asserts it's nil after. The session.id inequality check stays as a secondary signal.

MFA TTL test spurious pass (mfa_controller_test.rb:138): Added two negative assertions — session[:mfa_user_id] is nil after the TTL-expired attempt, and the count of Session rows for the user didn't change (no auth session created despite a valid OTP).

All changes in commit 2e7b76c0. Full suite: 3238 runs, 0 failures. Rubocop + brakeman clean.

— wolfgang 🤖

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
app/controllers/concerns/authentication.rb (2)

30-36: TTL constants: consider making them environment-configurable.

Hard-coded 30-day / 24-hour TTLs are reasonable defaults, but operators running self-hosted or compliance-sensitive deployments typically want to tune session lifetime without patching. Exposing these via ENV (with the current values as fallbacks) would align with the guideline to prefer environment variables over hard-coded configuration values, and makes incident response (forcing shorter TTLs) easier.

♻️ Optional refactor
-    SESSION_ABSOLUTE_TTL = 30.days
-    SESSION_IDLE_TTL = 24.hours
-    SESSION_TOUCH_THROTTLE = 1.minute
+    SESSION_ABSOLUTE_TTL  = (ENV["SESSION_ABSOLUTE_TTL_DAYS"]&.to_i&.days) || 30.days
+    SESSION_IDLE_TTL      = (ENV["SESSION_IDLE_TTL_HOURS"]&.to_i&.hours) || 24.hours
+    SESSION_TOUCH_THROTTLE = 1.minute

As per coding guidelines: "Use environment variables instead of hard-coded values in configuration."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/concerns/authentication.rb` around lines 30 - 36, Replace the
hard-coded TTL constants (SESSION_ABSOLUTE_TTL, SESSION_IDLE_TTL,
SESSION_TOUCH_THROTTLE) with values read from environment variables (e.g.
ENV.fetch('SESSION_ABSOLUTE_TTL_DAYS', '30').to_i.days,
ENV.fetch('SESSION_IDLE_TTL_HOURS', '24').to_i.hours,
ENV.fetch('SESSION_TOUCH_THROTTLE_SECONDS', '60').to_i.seconds) so operators can
override defaults; ensure parsing/conversion to ActiveSupport::Duration is
applied and fallbacks preserve current behavior if the ENV vars are absent or
invalid, and keep the same constant names so existing code (e.g. methods
referencing SESSION_ABSOLUTE_TTL) continues to work.

82-91: Use request.ssl? to enable secure flag for HTTPS environments beyond production.

The secure: Rails.env.production? check fails to set the Secure flag on the session_token cookie in staging, Codespaces, review apps, and self-hosted deployments running over HTTPS with a non-production Rails environment. This is inconsistent with config/initializers/codespaces.rb, which sets secure: true on the Rails session store for Codespaces (even though it's a development environment) precisely because Codespaces is HTTPS-only.

The request.ssl? check reliably detects the actual TLS status of the current request and will properly secure the cookie wherever HTTPS is in use, regardless of environment or deployment type.

♻️ Suggested change
       cookies.signed.permanent[:session_token] = {
         value: session.id,
         httponly: true,
-        secure: Rails.env.production?,
+        secure: request.ssl?,
         same_site: :lax
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/concerns/authentication.rb` around lines 82 - 91, The secure
flag on the session cookie is currently tied to Rails.env.production? which
misses HTTPS deployments outside production; in create_session_for change the
cookie options so secure is determined by the actual request TLS status (use
request.ssl? or equivalent) instead of environment; update the
cookies.signed.permanent[:session_token] options (the hash with value, httponly,
secure, same_site) to set secure: request.ssl? so staging/Codespaces/self-hosted
HTTPS requests get Secure set.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/controllers/concerns/authentication.rb`:
- Around line 30-36: Replace the hard-coded TTL constants (SESSION_ABSOLUTE_TTL,
SESSION_IDLE_TTL, SESSION_TOUCH_THROTTLE) with values read from environment
variables (e.g. ENV.fetch('SESSION_ABSOLUTE_TTL_DAYS', '30').to_i.days,
ENV.fetch('SESSION_IDLE_TTL_HOURS', '24').to_i.hours,
ENV.fetch('SESSION_TOUCH_THROTTLE_SECONDS', '60').to_i.seconds) so operators can
override defaults; ensure parsing/conversion to ActiveSupport::Duration is
applied and fallbacks preserve current behavior if the ENV vars are absent or
invalid, and keep the same constant names so existing code (e.g. methods
referencing SESSION_ABSOLUTE_TTL) continues to work.
- Around line 82-91: The secure flag on the session cookie is currently tied to
Rails.env.production? which misses HTTPS deployments outside production; in
create_session_for change the cookie options so secure is determined by the
actual request TLS status (use request.ssl? or equivalent) instead of
environment; update the cookies.signed.permanent[:session_token] options (the
hash with value, httponly, secure, same_site) to set secure: request.ssl? so
staging/Codespaces/self-hosted HTTPS requests get Secure set.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dbfc664f-ce7e-4395-a90d-c5dfd2b8aadd

📥 Commits

Reviewing files that changed from the base of the PR and between d8c6520 and 2e7b76c.

📒 Files selected for processing (6)
  • app/controllers/concerns/authentication.rb
  • app/controllers/impersonation_sessions_controller.rb
  • app/controllers/mfa_controller.rb
  • app/controllers/sessions_controller.rb
  • test/controllers/mfa_controller_test.rb
  • test/controllers/sessions_controller_test.rb
✅ Files skipped from review due to trivial changes (1)
  • test/controllers/sessions_controller_test.rb
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/controllers/mfa_controller_test.rb
  • app/controllers/mfa_controller.rb
  • app/controllers/sessions_controller.rb
  • app/controllers/impersonation_sessions_controller.rb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants