Skip to content

FIX Don't set session value in endpoint accessed from mail link. #11686

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

Conversation

GuySartorelli
Copy link
Member

@GuySartorelli GuySartorelli commented Apr 10, 2025

With the session cookie's "SameSite" value set to "Strict", we can't set session values when a request is the result of clicking a link from another website.
When a user clicks the reset password link from an email within a web client (e.g. Gmail), this results in not being able to reset their password.

This change sets the session value after the redirect, while avoiding putting the reset token into the browser history which could allow a bad actor to access it.

This change also allows that flow to work for silverstripe/mfa which needs to alter the second part of the flow, but detects that currently using the session value's presence or absence.

This PR is based off #11668 and #11676 though it does have some differences.

Issue

Comment on lines +114 to +127
// If a member or password reset token was included on its own, the URL was invalid.
if ($token !== null || $member !== null) {
return $this->getInvalidTokenResponse();
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This condition wasn't part of the original PRs and handles an edge case where only one of ?t or ?m is included in the URL (won't happen normally but can happen if someone copies a URL incorrectly for example)

Comment on lines +424 to +460
private function getInvalidTokenResponse(): array
{
return [
'Content' => DBField::create_field(
'HTMLFragment',
_t(
Security::class . '.NOTERESETLINKINVALID',
'<p>The password reset link is invalid or expired.</p>'
. '<p>You can request a new one <a href="{link1}">here</a> or change your password after'
. ' you <a href="{link2}">log in</a>.</p>',
[
'link1' => Security::lost_password_url(),
'link2' => Security::login_url(),
]
)
),
];
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This returns a message that used to only be returned in one place - but is now used to give consistent message any time the token is found to be invalid or expired at any point during the process.

*/
protected function setSessionToken($member, $token)
protected function setSessionToken(Member $member, string $token, bool $alreadyEncrypted = false): void
Copy link
Member Author

Choose a reason for hiding this comment

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

I've opted to keep the existing method name with the same default behaviour it has in CMS 5 so we don't have to deprecate it.

Comment on lines -89 to +170
'SilverStripe\\Security\\Security.ENTERNEWPASSWORD',
Security::class . '.ENTERNEWPASSWORD',
Copy link
Member Author

Choose a reason for hiding this comment

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

Unrelated refactoring - happens a few times in this PR.

$autoLoginTempHash = $this->createAutoLoginTempHash();
$member->AutoLoginTempHash = $autoLoginTempHash;
$member->write();
$response = $this->redirect(Controller::join_links($this->link, '?th=' . $autoLoginTempHash));
Copy link
Member Author

Choose a reason for hiding this comment

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

Using Controller::join_links() here to catch the (unlikely) edge case where the link already has some query string. join_links() will merge the query strings together so we don't end up with some/path?var=1?th=123

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch 2 times, most recently from 9f7f93f to eafec7d Compare April 10, 2025 03:59
@SimulatedPanda
Copy link

I approve this fix

Copy link
Member

@emteknetnz emteknetnz left a comment

Choose a reason for hiding this comment

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

We should not include the 2nd commit, it's an enhancement basically unrelated to the original issue it doesn't belong in this bugfix PR. Also the default lifetime of the token is 2 days, since it's so long it's questionable if it's even worth making a friendly message at all.

Also you'll need to create a changelog PR telling people with custom authenicators to update the 'changepassword' method names

@GuySartorelli
Copy link
Member Author

GuySartorelli commented Apr 10, 2025

We should not include the 2nd commit, it's an enhancement basically unrelated to the original issue it doesn't belong in this bugfix PR.

Usually the distinction of enhancement vs bug fix is to determine whether a change should be included in a patch release or a minor release - which doesn't apply here. I'm not sure how that distinction is helpful in this context?

The second commit provides standardised messaging when a reset password token expires. Due to the way it changes the response return flow, I would hesitate to include this change in a minor release when we have the opportunity now to include it in a major release.

The second commit was part of the change I explained to Jenn and was approved, so from a "are we allowed to include it" perspective - yes we are.

Also the default lifetime of the token is 2 days, since it's so long it's questionable if it's even worth making a friendly message at all.

That value is configurable, and we both were surprised by how long it is by default. I wouldn't be surprised to find projects updating it to be only valid for a couple of hours at most. That's plenty of time to step away, have a meeting or go for lunch or any number of things, and then submit the form when you come back.

tl;dr I'm not sure what your reasoning is for not including the second commit. If there's a problem with it that I'm not seeing, I'll be happy to exclude it, but I need to know what that problem is first.

@emteknetnz
Copy link
Member

emteknetnz commented Apr 10, 2025

I don't like the 2nd commit for several reasons:

  • It's out of scope - the second commit is basically a random "scope creep" enhancement rather than a bugfix for the original issue, it should really be done on a separate card
  • Incorrect process - The enhancement is adding API post beta, which we're supposed to avoid doing where possible. Correct process would be to target 6.1
  • Makes peer review harder - The enhancement is on PR that affects a critical security flow, I don't want to have to think about it at the same time as a PR that already has a lot of nuance and risk

@GuySartorelli
Copy link
Member Author

GuySartorelli commented Apr 10, 2025

It's out of scope - the second commit is basically a random "scope creep" enhancement rather than a bugfix for the original issue, it should really be done on a separate card

I had considered doing it separately, but since I was hesitant to include it in a minor release and it is in-line with the other related changes to the standardisation of the message that I'm including in commit 1, it seemed like a good fit for this PR.

Incorrect process - The enhancement is adding API post beta, which we're supposed to avoid doing where possible. Correct process would be to target 6.1

It was already approved - and like I said, I'd be hesitant to do this in a minor release.

Makes peer review harder - The enhancement is on PR that affects a critical security flow, I don't want to have to think about it at the same time as a PR that already has a lot of nuance and risk

It's in a second commit so you can look at the first commit in isolation from the second commit.
The first commit is 90% your work anyway.

I recommend you look at the first commit, ensure you're happy with that, then look at the (very narrow scope very small) second commit after you're happy with the first one. I think you'll find the overhead that second commit adds is negligible.

It sounds like you don't actually have a problem with the code or the change itself, which should be the primary concern IMO.

@GuySartorelli
Copy link
Member Author

If you still find it hard to review for some reason with two commits, let me know and I will split the second commit off as a separate PR in this issue with the understanding it will be reviewed and merged into 6.0 after this one

@emteknetnz
Copy link
Member

Yup 2nd PR would be helpful thanks

Also remember to:

Also you'll need to create a changelog PR telling people with custom authenicators to update the 'changepassword' method names

@GuySartorelli
Copy link
Member Author

Also you'll need to create a changelog PR telling people with custom authenicators to update the 'changepassword' method names

Done, please see link in issue

@GuySartorelli
Copy link
Member Author

Second commit has been separated out into #11689

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from 57907c7 to 44568de Compare April 10, 2025 22:59
Copy link
Member

@emteknetnz emteknetnz left a comment

Choose a reason for hiding this comment

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

The ?th hash for the redirect is non-predictable so gets updated multiple times if there are multiple authenticators. Presumably that processChangePasswordUrlVars() gets called multiple times.

Debugging on line 866 of Security::delegateToMultipleHandlers() - you can see there are 2x 302 responses with different th hashes.

I think what's written to the Member table isn't matching the redirect the browser makes. Means that that you end up with a The password reset link is invalid or expired. when you click the email link to reset password

Perhaps use an existing tempory hash on the member table if there is one rather than creating a new one?

# app/_config/myconfig.yml
SilverStripe\Core\Injector\Injector:
  SilverStripe\Security\Security:
    properties:
      Authenticators:
        hello: '%$HelloAuthenticator'
<?php
// app/src/HelloAuthenticator.php
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;

class HelloAuthenticator extends MemberAuthenticator
{
    public function getChangePasswordHandler($link)
    {
        return HelloChangePasswordHandler::create($link, $this);
    }
}
<?php
// app/src/HelloChangePasswordHandler.php
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\MemberAuthenticator\ChangePasswordHandler;

class HelloChangePasswordHandler extends ChangePasswordHandler
{
    private static $allowed_actions = [
        'changepassword',
    ];

    protected function createChangePasswordResponse(): array|HTTPResponse
    {
        return new HTTPResponse('Hello from HelloChangePasswordHandler');
    }
}

@GuySartorelli
Copy link
Member Author

Can you please write some step-by-step instructions to get into that many-redirects state? I don't quite understand what you mean so being able to reproduce it myself will help a lot.

@emteknetnz
Copy link
Member

emteknetnz commented Apr 13, 2025

  1. Add the code sample above to a project and flush
  2. Create a new user in the CMS [email protected] and give them to a user group
  3. In a browser b, not logged in, click "I forgot password link" for [email protected]
  4. In browser b, go to reset password link sent in email

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from 44568de to e57834e Compare April 14, 2025 00:52
@GuySartorelli
Copy link
Member Author

Good catch! I've updated to reuse the existing temp hash if multiple authenticators try to set it in the same request.

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from e57834e to 5740835 Compare April 14, 2025 01:03
use SilverStripe\Control\Session;
use SilverStripe\Core\Config\Config;
Copy link
Member Author

Choose a reason for hiding this comment

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

Config class not used

@GuySartorelli
Copy link
Member Author

CI failures are unrelated and will be fixed in silverstripe/.github#389

Copy link
Member

@emteknetnz emteknetnz left a comment

Choose a reason for hiding this comment

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

Latest changes do not appear to work using the HelloAuthenticator code supplied, tried on a user with an MFA enabled and one without, same result, get redirected to the ?th url and I get a message that the token is invalid

I've debugged the reason to here ChangePasswordHandler::processChangePasswordUrlVars():

        $tempToken = $request->getVar('th');
        if ($tempToken !== null) {
            $member = Member::get()->find('AutoLoginTempHash', $tempToken);
            if (!$member) {
                return $this->getInvalidTokenResponse(); // <<<<<<< 2nd time
            }
            // Delete the temp token from the member so that it cannot be used again
            // This prevents the browser history from being used to access the change password form
            $member->AutoLoginTempHash = ''; // <<<<<<< 1st time
            $member->write();
            // ...

What's happening the ?th code blocks works correctly the first time and sets AutoLoginTempHash to blank string. However the 2nd time around (because of the 2nd authenticator) it hits this code again and it cannot find a corresponding member

@GuySartorelli
Copy link
Member Author

Can you please show me a video (or in person) what that looks like from a user perspective? It worked for me locally but I may be doing something slightly different to what you're doing.

@emteknetnz
Copy link
Member

emteknetnz commented Apr 14, 2025

I don't think we need a video - it's exactly same flow as the replication steps, I followed those steps and the invalid token method was triggered i.e. the user is not shown the new + confirm password fields

  • If you try debugging the code block above when you have two authenticators does that code block get hit twice?
  • On the second time through there in no Member with a AutoLoginTempHash?
  • Are you using the exact same PHP classes foe the custom authenticator or you using something slightly different? (as some point I changed my own HelloChangePasswordHandler::createChangePasswordResponse() to return an empty array instead of HTTPResponse)
<?php

use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\MemberAuthenticator\ChangePasswordHandler;

class HelloChangePasswordHandler extends ChangePasswordHandler
{
    private static $allowed_actions = [
        'changepassword',
    ];

    protected function createChangePasswordResponse(): array|HTTPResponse
    {
        // return new HTTPResponse('Hello from HelloChangePasswordHandler');
        return [];
    }
}

@GuySartorelli
Copy link
Member Author

That change to the HelloChangePasswordHandler is what was different between my test and yours.

What was happening before:

I had removed the implementation of HelloCahngePasswordHandler::createChangePasswordResponse(), so it was just using the implementation in ChangePasswordHandler::createChangePasswordResponse().

  1. Security::delegateToMultipleHandlers() passed the request to both authenticators
  2. HelloChangePasswordHandler::createChangePasswordResponse() returned an HTTPResponse after successfully setting the tokens correctly. The response included the form.
  3. ChangePasswordHandler::createChangePasswordResponse() returned an array after being unsuccessful in setting the tokens.
  4. Security::delegateToMultipleHandlers() passed those two returned values into Security::aggregateAuthenticatorResponses(), which returned the HTTPResponse`.

Now they're both arrays.
The first array doesn't contain a 'Content' or a 'Form' key, it Security assumes it failed to deal with the tokens (or rather that the tokens weren't relevant for it). It moves on to see if the next authenticator has anything for it. The next one has a 'Content' key with the failure message.

The problem is that the implementation in HelloChangePasswordHandler::createChangePasswordResponse() is invalid.

Potential solutions

  1. No further changes. The way multiple authenticators are handled in series is weird, but not in scope for this issue. A valid implementation of createChangePasswordResponse() will return the form when $this->getRequest()->getSession()->get('AutoLoginHash') is a non-null valid hash.
  2. Add another private static property to retain the temp hash and ID of the member for the duration of this session, so that even after AutoLoginTempHash has been cleared for the member, we still know it was a valid hash when the request began.

I lean towards 1, mostly because this happening is very edge-casey but partly because I think any valid implementation won't hit this problem.

That said, I don't feel at all strongly about it, so defer to you to decide which of these (or something else I didn't think of) to do.

@emteknetnz
Copy link
Member

emteknetnz commented Apr 14, 2025

Add the following code to ChangePasswordHandler instead, have tested it works locally no matter what nonsense is returned from the custom password handler

    /**
     * Keep track of whether a temporary hash is already processed during this request cycle.
     * @internal
     */
    private static bool $tempHashAlreadyProcessed = false;
    
    // processChangePasswordUrlVars()
    // ...
        $tempToken = $request->getVar('th');
        if ($tempToken !== null && !ChangePasswordHandler::$tempHashAlreadyProcessed) { // <<<< update this
            // ...
            $this->setSessionToken($member, $encryptedToken, true);
            ChangePasswordHandler::$tempHashAlreadyProcessed = true; // <<<< add this
        }

@GuySartorelli
Copy link
Member Author

Modified option 2 it is 👍

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from 5740835 to 86a56f2 Compare April 14, 2025 22:06
@GuySartorelli
Copy link
Member Author

Rebased on 6.0 to clear out those unrelated CI failures

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from 86a56f2 to b83de35 Compare April 14, 2025 23:10
@GuySartorelli
Copy link
Member Author

Added new behaviour to ignore invalid handler results if there are valid results from another handler, including test.

$response = $security->changepassword();
$this->assertInstanceOf(DBHTMLText::class, $response);
$this->assertStringNotContainsString('The password reset link is invalid or expired', $response->getValue());
$this->assertStringContainsString('Please enter a new password', $response->getValue());
Copy link
Member Author

Choose a reason for hiding this comment

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

I would have checked for the presence of the form, except there isn't one due to silverstripe/startup-theme#17

Not sure how other tests in here that rely on the form are passing 😅

@@ -655,7 +655,7 @@ public static function clearSessionMessage()
*
* @param null|HTTPRequest $request
* @param int $service
* @return HTTPResponse|string Returns the "login" page as HTML code.
* @return HTTPResponse|RequestHandler|DBHTMLText|string Returns the "login" page as HTML code.
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated PHPDocs in this class match return type hint for delegateToMultipleHandlers() which is the value all of these methods return.

@@ -884,7 +884,7 @@ function (RequestHandler $handler) {
* @param RequestHandler $handler
* @param string $title The title of the form
* @param array $templates
* @return array|HTTPResponse|RequestHandler|DBHTMLText|string
* @return HTTPResponse|RequestHandler|DBHTMLText|string
*/
protected function delegateToHandler(RequestHandler $handler, $title, array $templates = [])
Copy link
Member Author

Choose a reason for hiding this comment

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

This and delegateToMultipleHandlers() explicitly cannot return an array, because they transform arrays with a call to renderWrappedController()

@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from b83de35 to b5a3574 Compare April 14, 2025 23:27
With the session cookie's "SameSite" value set to "Strict", we can't set
session values when a request is the result of clicking a link from
another website.
When a user clicks the reset password link from an email within a web
client (e.g. Gmail), this results in not being able to reset their
password.

This change sets the session value _after_ the redirect, while avoiding
putting the reset token into the browser history which could allow a bad
actor to access it.

This change also allows that flow to work for silverstripe/mfa which
needs to alter the second part of the flow, but detects that currently
using the session value's presence or absence.
@GuySartorelli GuySartorelli force-pushed the pulls/6.0/changepassword-strict branch from b5a3574 to a7a962e Compare April 14, 2025 23:43
@emteknetnz emteknetnz merged commit 4b5cbd7 into silverstripe:6.0 Apr 15, 2025
12 checks passed
@emteknetnz emteknetnz deleted the pulls/6.0/changepassword-strict branch April 15, 2025 00:53
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.

3 participants