silverstripe / silverstripe-framework

Silverstripe Framework, the MVC framework that powers Silverstripe CMS
https://www.silverstripe.org
BSD 3-Clause "New" or "Revised" License
720 stars 820 forks source link

Remember Me functionality fails when renewal is interrupted #11281

Open Cheddam opened 1 week ago

Cheddam commented 1 week ago

Module version(s) affected

Tested against 5.2.x but related logic introduced in 4.x

Description

The 'Remember Me' feature stores two additional cookies in the user's browser that enable Silverstripe CMS to renew the user's session when it is destroyed (either through a timeout on the server side or when the user closes/reopens their browser.)

The alc_device cookie is static throughout the lifetime of the login, but the alc_enc cookie value is rotated whenever the session is renewed. This makes some sense from a security perspective, in the same vein as CSRF token rotation, but it relies on the browser receiving the new value, which is not guaranteed - network issues or user behaviour could result in a failure or cancellation of the request prior to headers being returned to the browser.

In any case where the browser does not receive the new cookie value, any subsequent request will transmit the original, which no longer maps to an active RememberLoginHash, triggering a logout.

How to reproduce

  1. Log into a Silverstripe CMS instance with the 'Remember Me' box checked
  2. Delete the PHPSESSID / SECSESSID cookie to simulate expiry of the session (or wait for it to time out)
  3. Trigger a cancelled request from the browser
    • The request must progress far enough to be processed by the server in order to trigger the renewal (i.e. after initial connection and handshake)
    • The most reliable way I've reproduced this is through repeatedly clicking a link in the front-end of the site that loads a slow route (you can pop sleep(10) in the relevant controller method to simulate this)
  4. Load an authenticated route, observe kick to login screen

Possible Solution

I see two potential avenues to resolution:

Option 1: Remove/disable rotation logic

As previously mentioned, the rotation does make some sense from a security standpoint, but the risk it mitigates is arguably minimal. If an attacker can steal the alc_enc cookie, they can also steal the SECSESSID cookie, and they will likely exploit it while it is fresh regardless.

Based on this, either removing the rotation step entirely, or allowing it to be disabled in configuration, seems like a viable solution. To be clear, this should only affect rotation during the lifetime of a login - explicit logouts and new logins should still generate a fresh value.

For a quick comparison, I took a look at Laravel's equivalent implementation and it appears that they do not perform rotation of the token during the lifetime of the login.

Option 2: Introduce a grace period

This would be more complex to implement / maintain, and the exact parameters would need to be considered, but in essence we could mitigate this failure by respecting requests with a stale alc_enc within a small window and re-transmitting the renewed value.

Additional Context

No response

Validations

GuySartorelli commented 1 week ago

Can confirm both from the actual src code and the tests of the laravel implementation that they don't cycle the token except to set it during initial login (and if it's missing) and to remove it when logging out.

I'd say that's the way to go.

It does look like Syfony cycles it (if it hadn't already been cycled in the last minute): https://github.com/symfony/symfony/blob/a86c96a85931f98e1ba6275629c3fcc268990527/src/Symfony/Component/Security/Http/RememberMe/AbstractRememberMeHandler.php#L47-L56

https://github.com/symfony/symfony/blob/a86c96a85931f98e1ba6275629c3fcc268990527/src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php#L91-L100

So I'm not sure if they have similar issues to what this issue describes.

GuySartorelli commented 1 week ago

If an attacker can steal the alc_enc cookie, they can also steal the SECSESSID cookie, and they will likely exploit it while it is fresh regardless.

That does depend on whether they have a reliable attack method that they can repeat, or if it's once-off. If it's once-off, then cycling alc_enc will stop them from being able to access your account even if they have the main session cookie. So it is slightly more secure to keep cycling that, I think? I dunno.

Given Laravel doesn't bother with it I'm inclined to accept that it's not doing a lot of good, but I'd like other opinions from @silverstripe/core-team if anyone has one.

GuySartorelli commented 1 week ago

@Cheddam if no one replies in say a week or so, feel free to raise a PR and we can proceed from there.

madmatt commented 1 week ago

Removing the recycling of these would result in many more entries in the RememberLoginHash table, no? Or would you just expect the original hash to just live forever so you'd still only have one per device per Member?

I'm not sure I really see the use case for removing the existing functionality myself - sounds like this was a transient network issue that caused a request to be sent and processed but the HTTP response was not received by the browser so they never found out about the new alc_enc cookie? Without adding your sleep, how reproducible is it? Is it solving any other problem to remove this?

Agree that the rotation itself doesn't really achieve very much - as long as you're running over HTTPS you should be pretty safe from attack and/or the SECSESSID cookie is of more immediate value.

Cheddam commented 1 week ago

With Option 1, there'd still only be one RememberLoginHash entry per autologin. With Option 2, there would be multiple valid hashes, but we'd potentially mark outdated ones and significantly shorten their expiry. I haven't fully explored the implementation details yet, as I'm hoping we can just go with Option 1.

There's a range of potential triggers for the response not getting to the browser - a user double-clicking a link, multiple tabs of the same site rehydrating when a user reopens their browser, a user going through a tunnel and spamming refresh, a SPA firing off multiple asynchronous API requests to update the UI (this one may not trigger a full logout as the new value would still reach the browser, but could still trigger a failure in other requests sent before the new cookies arrived.)

One more wrinkle that we'd need to account for in Option 2 is if multiple requests resolve and successfully relay new cookies to the browser, but the responses are out of order, resulting in now-outdated cookies being set. (I really think Option 1 is going to be the best path forward.)