Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/Auth/UserTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ public function forgotPasswordForm()
$params['error_redirect'] = $this->parseRedirect($errorRedirect);
}

if ($resetUrl = $this->params->get('reset_url')) {
if ($resetUrl = $this->getPasswordResetUrl($this->params->get('reset_url'))) {
$params['reset_url'] = $resetUrl;
}

Expand All @@ -374,6 +374,19 @@ public function forgotPasswordForm()
return $html;
}

private function getPasswordResetUrl(?string $url = null): ?string
{
if (! $url) {
return null;
}

if (! URL::isAbsolute($url) && ! str_starts_with($url, '/')) {
$url = '/'.$url;
}

return encrypt($url);
}

/**
* Output a reset password form.
*
Expand Down
35 changes: 29 additions & 6 deletions src/Http/Controllers/ForgotPasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

namespace Statamic\Http\Controllers;

use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Inertia\Inertia;
use Statamic\Auth\Passwords\PasswordReset;
use Statamic\Auth\SendsPasswordResetEmails;
use Statamic\Exceptions\ValidationException;
use Statamic\Facades\URL;
use Statamic\Http\Middleware\RedirectIfAuthenticated;

Expand All @@ -32,17 +32,40 @@ public function showLinkRequestForm()

public function sendResetLinkEmail(Request $request)
{
if ($url = $request->_reset_url) {
throw_if(URL::isExternalToApplication($url), ValidationException::withMessages([
'_reset_url' => trans('validation.url', ['attribute' => '_reset_url']),
]));

if ($url = $this->getResetFormUrl($request)) {
PasswordReset::resetFormUrl(URL::makeAbsolute($url));
}

return $this->traitSendResetLinkEmail($request);
}

private function getResetFormUrl(Request $request): ?string
{
if (! $url = $request->_reset_url) {
return null;
}

if (strlen($url) > 2048) {
return null;
}

try {
$url = decrypt($url);
} catch (DecryptException $e) {
if (! str_starts_with($url, '/') || str_starts_with($url, '//')) {
return null;
}

if (preg_match('/[\x00-\x1F\x7F]/', $url)) {
return null;
}

return $url;
}

return URL::isExternalToApplication($url) ? null : $url;
}

public function broker()
{
$broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS);
Expand Down
133 changes: 120 additions & 13 deletions tests/Auth/ForgotPasswordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Facades\Password;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Auth\Passwords\PasswordReset;
use Statamic\Facades\User;
use Tests\Facades\Concerns\ProvidesExternalUrls;
use Tests\PreventSavingStacheItemsToDisk;
Expand All @@ -26,6 +27,8 @@ public function setUp(): void
{
parent::setUp();

PasswordReset::resetFormUrl(null);

$this->setSites([
'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'],
'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'],
Expand All @@ -34,28 +37,132 @@ public function setUp(): void
}

#[Test]
#[DataProvider('externalUrlProvider')]
public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal)
public function it_accepts_encrypted_reset_url_when_sending_reset_link_email()
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

User::make()
->email('san@holo.com')
->password('chewy')
->save();
$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => encrypt('http://this-site.com/some-path'),
])->assertSessionHasNoErrors();
$this->assertEquals('http://this-site.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets'));
}

$response = $this->post('/!/auth/password/email', [
#[Test]
public function it_accepts_unencrypted_relative_reset_url_when_sending_reset_link_email()
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => '/some-path',
])->assertSessionHasNoErrors();
$this->assertEquals('http://absolute-url-resolved-from-request.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets'));
}

#[Test]
#[DataProvider('externalResetUrlProvider')]
public function it_rejects_unencrypted_external_reset_url_when_sending_reset_link_email($url)
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => $url,
]);
])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

#[Test]
public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_reset_link_email()
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => 'http://this-site.com/some-path',
])->assertSessionHasNoErrors();
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

if ($isExternal) {
$response->assertSessionHasErrors(['_reset_url']);
#[Test]
public function it_rejects_unencrypted_relative_reset_url_with_control_characters_when_sending_reset_link_email()
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

return;
}
$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => "/some-path\r\nLocation: https://evil.com",
])->assertSessionHasNoErrors();
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

$response->assertSessionHasNoErrors();
#[Test]
public function it_rejects_reset_url_longer_than_2048_characters_when_sending_reset_link_email()
{
$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => '/'.str_repeat('a', 2048),
])->assertSessionHasNoErrors();
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

#[Test]
public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email()
{
// Unencrypted string that doesn't look like a URL is probably a tampered encrypted string.
// It might be a relative url without a leading slash, but we won't treat it as that.

$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => 'not-an-encrypted-string',
])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

#[Test]
#[DataProvider('externalResetUrlProvider')]
public function it_rejects_encrypted_external_reset_url_when_sending_reset_link_email($url)
{
// It's weird to point to an external URL, even if you encrypt it yourself.
// This is an additional safeguard.

$this->simulateSuccessfulPasswordResetEmail();
$this->createUser();

$this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => encrypt($url),
])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
$this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
}

public static function externalResetUrlProvider()
{
$keyFn = function ($key) {
return is_null($key) ? 'null' : $key;
};

return collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url]])->all();
}

private function createUser(): void
{
User::make()
->email('san@holo.com')
->password('chewy')
->save();
}

protected function simulateSuccessfulPasswordResetEmail()
Expand Down
Loading
Loading