55use Illuminate \Support \Facades \Password ;
66use PHPUnit \Framework \Attributes \DataProvider ;
77use PHPUnit \Framework \Attributes \Test ;
8+ use Statamic \Auth \Passwords \PasswordReset ;
89use Statamic \Facades \User ;
910use Tests \Facades \Concerns \ProvidesExternalUrls ;
1011use Tests \PreventSavingStacheItemsToDisk ;
@@ -26,6 +27,8 @@ public function setUp(): void
2627 {
2728 parent ::setUp ();
2829
30+ PasswordReset::resetFormUrl (null );
31+
2932 $ this ->setSites ([
3033 'a ' => ['name ' => 'A ' , 'locale ' => 'en_US ' , 'url ' => 'http://this-site.com/ ' ],
3134 'b ' => ['name ' => 'B ' , 'locale ' => 'en_US ' , 'url ' => 'http://subdomain.this-site.com/ ' ],
@@ -34,28 +37,132 @@ public function setUp(): void
3437 }
3538
3639 #[Test]
37- #[DataProvider('externalUrlProvider ' )]
38- public function it_validates_reset_url_when_sending_reset_link_email ($ url , $ isExternal )
40+ public function it_accepts_encrypted_reset_url_when_sending_reset_link_email ()
3941 {
4042 $ this ->simulateSuccessfulPasswordResetEmail ();
43+ $ this ->createUser ();
4144
42- User::make ()
43- ->email ('san@holo.com ' )
44- ->password ('chewy ' )
45- ->save ();
45+ $ this ->post ('/!/auth/password/email ' , [
46+ 'email ' => 'san@holo.com ' ,
47+ '_reset_url ' => encrypt ('http://this-site.com/some-path ' ),
48+ ])->assertSessionHasNoErrors ();
49+ $ this ->assertEquals ('http://this-site.com/some-path?token=test-token ' , PasswordReset::url ('test-token ' , 'resets ' ));
50+ }
4651
47- $ response = $ this ->post ('/!/auth/password/email ' , [
52+ #[Test]
53+ public function it_accepts_unencrypted_relative_reset_url_when_sending_reset_link_email ()
54+ {
55+ $ this ->simulateSuccessfulPasswordResetEmail ();
56+ $ this ->createUser ();
57+
58+ $ this ->post ('/!/auth/password/email ' , [
59+ 'email ' => 'san@holo.com ' ,
60+ '_reset_url ' => '/some-path ' ,
61+ ])->assertSessionHasNoErrors ();
62+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/some-path?token=test-token ' , PasswordReset::url ('test-token ' , 'resets ' ));
63+ }
64+
65+ #[Test]
66+ #[DataProvider('externalResetUrlProvider ' )]
67+ public function it_rejects_unencrypted_external_reset_url_when_sending_reset_link_email ($ url )
68+ {
69+ $ this ->simulateSuccessfulPasswordResetEmail ();
70+ $ this ->createUser ();
71+
72+ $ this ->post ('/!/auth/password/email ' , [
4873 'email ' => 'san@holo.com ' ,
4974 '_reset_url ' => $ url ,
50- ]);
75+ ])->assertSessionHasNoErrors (); // Allow the notification to be sent, but without the bad url.
76+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
77+ }
78+
79+ #[Test]
80+ public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_reset_link_email ()
81+ {
82+ $ this ->simulateSuccessfulPasswordResetEmail ();
83+ $ this ->createUser ();
84+
85+ $ this ->post ('/!/auth/password/email ' , [
86+ 'email ' => 'san@holo.com ' ,
87+ '_reset_url ' => 'http://this-site.com/some-path ' ,
88+ ])->assertSessionHasNoErrors ();
89+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
90+ }
5191
52- if ($ isExternal ) {
53- $ response ->assertSessionHasErrors (['_reset_url ' ]);
92+ #[Test]
93+ public function it_rejects_unencrypted_relative_reset_url_with_control_characters_when_sending_reset_link_email ()
94+ {
95+ $ this ->simulateSuccessfulPasswordResetEmail ();
96+ $ this ->createUser ();
5497
55- return ;
56- }
98+ $ this ->post ('/!/auth/password/email ' , [
99+ 'email ' => 'san@holo.com ' ,
100+ '_reset_url ' => "/some-path \r\nLocation: https://evil.com " ,
101+ ])->assertSessionHasNoErrors ();
102+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
103+ }
57104
58- $ response ->assertSessionHasNoErrors ();
105+ #[Test]
106+ public function it_rejects_reset_url_longer_than_2048_characters_when_sending_reset_link_email ()
107+ {
108+ $ this ->simulateSuccessfulPasswordResetEmail ();
109+ $ this ->createUser ();
110+
111+ $ this ->post ('/!/auth/password/email ' , [
112+ 'email ' => 'san@holo.com ' ,
113+ '_reset_url ' => '/ ' .str_repeat ('a ' , 2048 ),
114+ ])->assertSessionHasNoErrors ();
115+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
116+ }
117+
118+ #[Test]
119+ public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email ()
120+ {
121+ // Unencrypted string that doesn't look like a URL is probably a tampered encrypted string.
122+ // It might be a relative url without a leading slash, but we won't treat it as that.
123+
124+ $ this ->simulateSuccessfulPasswordResetEmail ();
125+ $ this ->createUser ();
126+
127+ $ this ->post ('/!/auth/password/email ' , [
128+ 'email ' => 'san@holo.com ' ,
129+ '_reset_url ' => 'not-an-encrypted-string ' ,
130+ ])->assertSessionHasNoErrors (); // Allow the notification to be sent, but without the bad url.
131+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
132+ }
133+
134+ #[Test]
135+ #[DataProvider('externalResetUrlProvider ' )]
136+ public function it_rejects_encrypted_external_reset_url_when_sending_reset_link_email ($ url )
137+ {
138+ // It's weird to point to an external URL, even if you encrypt it yourself.
139+ // This is an additional safeguard.
140+
141+ $ this ->simulateSuccessfulPasswordResetEmail ();
142+ $ this ->createUser ();
143+
144+ $ this ->post ('/!/auth/password/email ' , [
145+ 'email ' => 'san@holo.com ' ,
146+ '_reset_url ' => encrypt ($ url ),
147+ ])->assertSessionHasNoErrors (); // Allow the notification to be sent, but without the bad url.
148+ $ this ->assertEquals ('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token? ' , PasswordReset::url ('test-token ' , 'resets ' ));
149+ }
150+
151+ public static function externalResetUrlProvider ()
152+ {
153+ $ keyFn = function ($ key ) {
154+ return is_null ($ key ) ? 'null ' : $ key ;
155+ };
156+
157+ return collect (static ::externalUrls ())->mapWithKeys (fn ($ url ) => [$ keyFn ($ url ) => [$ url ]])->all ();
158+ }
159+
160+ private function createUser (): void
161+ {
162+ User::make ()
163+ ->email ('san@holo.com ' )
164+ ->password ('chewy ' )
165+ ->save ();
59166 }
60167
61168 protected function simulateSuccessfulPasswordResetEmail ()
0 commit comments