Skip to content

RememberMe refresh can leave oudated token which leads to broken functionality #54192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
heiglandreas opened this issue Mar 7, 2024 · 10 comments

Comments

@heiglandreas
Copy link
Contributor

heiglandreas commented Mar 7, 2024

Symfony version(s) affected

6.3.11

Due to the way the code is written this still exists in 7.1

Description

Currently when a user encounters a problem during login via RememberMe functionality and the browser does not receive the new RememberMe token and the SessionCookie that same user can still log in when the old RememberMe token is cached.

With the next request they will again send the old RememberMe token and no Cookie so again a login will happen via the cached RememberMe token. Now the user will receive a SessionCookie and is now logged back into the applicaten.

But when the second login happened within 60 seconds from the first one, the user will - due to the change from #47488 - not receive the current RememberMe token. So the RememberMe Cookie will still contain the old token.

When the users session expires the RememberMe functionality should again kick in. But now the user still has the "old" token. When the old token is still in the token-cache the login will work and the user will now have an up-to-date rememberMe token.

But usually at that point the token cache has dropped the old token so that the token is considered to be stale and a CookieTheftException is raised.

What sounds like a really exotic edge-case causes about 3000 CookieTheftExceptions per day in our application. As the root cause can be any network interruption that causes the response of a RememberMe authentication to not reach the client it might actually trigger an immediate reload of the resource so that the second login actually happens rather often within the 60 second timeframe.

From #47488:

2 requests come by at the same time with tokenA:

* the one that wins the race persists and sends back tokenB

* the other one accepts tokenA but doesn't send any cookie back, aka tokenB from req 1 stays in the cookie jar.

Unless I missed something, this still fixes the situation that @heiglandreas describes in #46760 while preventing a needless disclosure of the currently valid token to the 2nd request.

The problem happens when the request that wins the race doesn't actually return to the client and then tokenB is lost in the void. The other request will be accepted with tokenA but the chain of RememberMe tokens is broken and the following authentication has to happen via other means - which defeats the purpose of having a RememberMe functionality.

How to reproduce

  • Log into an application using the RememberMe functionality and activate the RememberMe functionlaity for future logins
  • Make sure you have xdebug running and can stop a request at will
  • Delete the session-cookie so that the next request will trigger a RememberMe authentication
  • trigger a debugging request to an endpoint that will timeout. The important part is the timeout, not XDebug per se. It is just a way to slow down handling of the request until the client or the application server (like nginx) will timeout.
  • This will have triggered a RememberMe login and therefore we have the old RememberMe token in the cache and a new one in the database. As the connection timed out the client has not received the new token or a new session-ID
  • Within 60 seconds (this timing is important!) trigger another request without debugging (as above the debugging is irrelevant. The main thing is that this request does not time out). The request will submit the old token and cause a RememberMe authentication again. This time the old token is verified against the one in cache. Note that the response does not contain setting a new RememberMe token, only a new session token!
  • Now remove the old RememberMe token from the cache
  • Also remove the session cookie from the browser.
  • Now trigger the request again. The RememberMe functionality will not work and you will have to log in via alternative means.

Possible Solution

  • Always return the current RememberMe token when logging in via cached Rememberme token.
  • Return the current RememberMe token always when some additional information match the information from the creation/update of the token.

The new token will in any case be returned after the 60 second interval. So depending on how long the old token is cached (which can be much longer than 60 seconds depending on the implementation) there is no carefully planned attack needed but one just needs to wait for more than 60 seconds to authenticate again with the old token.

Additional Context

No response

@Seldaek
Copy link
Member

Seldaek commented Mar 7, 2024

cc @zerkms as this bug was reintroduced for us in #47488 - I would like to discuss the rationale behind this fix. I agree it would allow an attacker that has stolen a remember me cookie to permanently do requests to try and steal a new remember me cookie after a user reauthenticates. However, if you stole a remember me cookie you can just use it to open a session and then keep using that session for "ever" as long as you keep sending requests to keep it alive. So all in all I am unsure what the security benefits are, and it definitely causes problems in our real world usage.

@heiglandreas
Copy link
Contributor Author

Especially as the fix closed the window of the first 60 seconds after the login. But when the cache is longer lived a later authentication via RememberMe will return the new token. So the change is only a "security feature" (huge quotes) when the caching backend also expires the old tokens after 60 seconds. Which is not a given as the cache backend can be built custom-sided.

@zerkms
Copy link
Contributor

zerkms commented Mar 7, 2024

So the change is only a "security feature"

@heiglandreas I didn't try to sell my PR as a "security feature" - it was fixing a real typical use case scenario that was breaking users' experience.

The problem happens when the request that wins the race doesn't actually return to the client

It's not a problem of my PR, that's how rememberme protocol is designed to work: you exchange one token with the other. You have one chance for that. A lot of protocols work the same way (eg: oauth)

Always return the current RememberMe token when logging in via cached Rememberme token.

This breaks the security of the idea of series-based rememberme. This solution should come with removal of series-based logic, as it would become just unnecessary complexity and then detailed explanation of the new protocol. Also, it will reintroduce #42343 which hits happy path users.

So the change is only a "security feature" (huge quotes) when the caching backend also expires the old tokens after 60 seconds

If your caching layer leaks authentication credentials - it's not a problem of rememberme 🤷

What sounds like a really exotic edge-case causes about 3000 CookieTheftExceptions per day in our application. As the root cause can be any network interruption that causes the response of a RememberMe authentication to not reach the client it might actually trigger an immediate reload of the resource so that the second login actually happens rather often within the 60 second timeframe.

This looks suspicious that you have 3000 requests with remember me that fail. How much is it in the total value of remember calls? 0.5%? Or more?

@Seldaek

So all in all I am unsure what the security benefits are

My PR didn't fix a security issue, it fixed a logic issue originally explained in #42343 (security improvement is just a byproduct)

and it definitely causes problems in our real world usage.

My PR fixed the happy path: when all requests are successful. And you get it "broken" when some requests fail. I guess, the main priority should be to work "as designed" first, then handle exceptions: to me 2 concurrent successful requests is a primary case, and "a request that failed due to network connectivity issues" is secondary.

@zerkms
Copy link
Contributor

zerkms commented Mar 7, 2024

Okay, I have the third suggestion then:

Symfony stop throwing the CookieTheftExceptions exception: it's useless, and cannot really be handled. Instead - it emits an event and if you care about it - do whatever you want in a listener. And then after an event is emmitted rememberme handler clears rememberme cookie (as it's proven to be "wrong") and lets symfony firewall do the rest of its job.

So the unlucky users that lost their opportunity to exchange the rememberme with a new valid one, will next time just have to authenticate normally, not a big deal. And everybody happy, right?

@heiglandreas
Copy link
Contributor Author

I like the idea of triggering an event!

But that is not the problem here.

Currently we have a situation where multiple requests to a resource result in different responses based on the amount of time between the first and the second request.

So currently when someone sends a request with a RememberMe-Token and no session-ID they get assigned a new token. Which is fine.
And when - after some time they send a request again with that same Rememberme-Token and no sessison-ID they get

  • either the previous token assigned (request happens more than 60 seconds after the previous one)
  • or no token assigned at all (request happens less than 60 seconds after the previous one)

This is ... odd.

I would expect the same behaviour in both cases, especially as the 60 seconds is just any arbitrary hardcoded number.

I see two possible options that will not interfere with the happy path:

  • Always return the new token, not only after 60 seconds or
  • Make the time-interval in which no cookie is set configurable. (whether via an event or via a config value is perhaps a separate question

If we allow cached RememberMe tokens I would expect them to always work the same way when they are valid. I see this similar to 2FA where one can also configure to accept a certain number of "old" OTPs. As long as the old OTP is cached, I can use that as well as the current one to verify myself.

So the change is only a "security feature" (huge quotes) when the caching backend also expires the old tokens after 60 seconds
If your caching layer leaks authentication credentials - it's not a problem of rememberme 🤷

The caching layer is not leaking credentials. If at all it's the arbitrary number of 60 seconds that is leaking the credentials.

This looks suspicious that you have 3000 requests with remember me that fail. How much is it in the total value of remember calls? 0.5%? Or more?

Good point actually. Had to check that. It was more than 0.5%! Before modifying the cache-TTL we were at about 3%. Now we are down to 1%

So the unlucky users that lost their opportunity to exchange the rememberme with a new valid one, will next time just have to authenticate normally, not a big deal. And everybody happy, right?

Apart from the user on a flaky internet connection that constantly drops and that constantly - for no to the user apparent reason - has to enter username and password despite having activated the RememberMe feasture.

@zerkms
Copy link
Contributor

zerkms commented Mar 11, 2024

Okay, I have refreshed my memories about the original problems with the code, and current state. And also gave it a weekend of thinking. So, more weighted responses:

Currently we have a situation where multiple requests to a resource result in different responses based on the amount of time between the first and the second request.

It's fine, the rememberme protocol is stateful and is not idempotent.

either the previous token assigned (request happens more than 60 seconds after the previous one)

I cannot see that from the code: if it's more than 60 seconds, and the request contains the valid rememberme value: then the new token will be issued. In current code the token is never to be exposed after the first request. If it is exposed - it's a bug (but I don't see where it could come from).

If at all it's the arbitrary number of 60 seconds that is leaking the credentials.

https://github.com/symfony/symfony/blob/7.1/src/Symfony/Component/Security/Http/RememberMe/PersistentRememberMeHandler.php#L92 - nothing leaks anything here 🤷

Good point actually. Had to check that. It was more than 0.5%! Before modifying the cache-TTL we were at about 3%. Now we are down to 1%

I still am not sure what "cache" is being referred here, in rememberme implementation there is no any cache anywhere (or I possibly am looking at the wrong place). But 3% is a huge number for the discussed problem. To me it should happen extremely rarely.

Btw, for your particular use case (when you have incredibly high number of users with unsuccessful first request) - you possibly could tag your custom handler with security.remember_me_handler and override the response to always return the current rememberme token to every request, which makes the whole idea of series useless, but still - at least you wouldn't be having thousands of sentry reports a day?

@Seldaek
Copy link
Member

Seldaek commented Mar 11, 2024

This breaks the security of the idea of series-based rememberme. This solution should come with removal of series-based logic, as it would become just unnecessary complexity and then detailed explanation of the new protocol. Also, it will reintroduce #42343 which hits happy path users.

We can for sure argue about the series-based rememberme. Yes it is slightly less secure as it allows two persons to get the new value in the series, if both request with the same old rememberme token, AND both do it within 60 seconds.

Regarding the problem in #42343, I see the issue and I think it might be mitigated by re-fetching the token from DB before returning it again in the cookie. I'd need to look at the code again more closely to make sure.


Anyway I think we need to evaluate a bit more the problem on our end. I am not yet fully convinced this will resolve it, nor about the cause of the failed requests which seem to be way too high. So I'd say let's put this on hold, we'll first try to patch symfony on our end and see if it helps or not.

@carsonbot
Copy link

Hey, thanks for your report!
There has not been a lot of activity here for a while. Is this bug still relevant? Have you managed to find a workaround?

@carsonbot
Copy link

Just a quick reminder to make a comment on this. If I don't hear anything I'll close this.

@carsonbot
Copy link

Hey,

I didn't hear anything so I'm going to close it. Feel free to comment if this is still relevant, I can always reopen!

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

No branches or pull requests

6 participants