Skip to content

Commit cf367b3

Browse files
Merge branch '5.3' into 5.4
2 parents f93f0f9 + c00b559 commit cf367b3

File tree

7 files changed

+161
-43
lines changed

7 files changed

+161
-43
lines changed

src/Core/XssSanitiser.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ private function getStripAttributeContentsRegex(): string
201201
$this->splitWithWhitespaceRegex('vbscript:'),
202202
];
203203
// Regex is "starts with any of these, with optional whitespace at the start, case insensitive"
204-
return '#^\s*(' . implode('|', $regexes) . ')#iu';
204+
// \x08 is the backspace character, though it only causes vulnerabilities if it's at the start
205+
// or end of a string
206+
return '#^(\s|\x08)*(' . implode('|', $regexes) . ')(\s|\x08)*#iu';
205207
}
206208

207209
private function splitWithWhitespaceRegex(string $string): string

src/Security/MemberAuthenticator/LostPasswordHandler.php

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -159,38 +159,28 @@ public function redirectToLostPassword()
159159
*/
160160
public function forgotPassword(array $data, Form $form): HTTPResponse
161161
{
162-
// Run a first pass validation check on the data
163-
$dataValidation = $this->validateForgotPasswordData($data, $form);
164-
if ($dataValidation instanceof HTTPResponse) {
165-
return $dataValidation;
166-
}
167-
168-
$member = $this->getMemberFromData($data);
169-
170-
// Allow vetoing forgot password requests
171-
$results = $this->extend('forgotPassword', $member);
172-
if ($results && is_array($results) && in_array(false, $results ?? [], true)) {
173-
return $this->redirectToLostPassword();
174-
}
175-
176-
if ($member) {
177-
$token = $member->generateAutologinTokenAndStoreHash();
162+
return Security::withMinimumExecutionTime(function () use ($data, $form) {
163+
// Run a first pass validation check on the data
164+
$dataValidation = $this->validateForgotPasswordData($data, $form);
165+
if ($dataValidation instanceof HTTPResponse) {
166+
return $dataValidation;
167+
}
178168

179-
$success = $this->sendEmail($member, $token);
180-
if (!$success) {
181-
$form->sessionMessage(
182-
_t(
183-
Member::class . '.EMAIL_FAILED',
184-
'There was an error when trying to email you a password reset link.'
185-
),
186-
'bad'
187-
);
169+
$member = $this->getMemberFromData($data);
188170

171+
// Allow vetoing forgot password requests
172+
$results = $this->extend('forgotPassword', $member);
173+
if ($results && is_array($results) && in_array(false, $results ?? [], true)) {
189174
return $this->redirectToLostPassword();
190175
}
191-
}
192176

193-
return $this->redirectToSuccess($data);
177+
if ($member) {
178+
$token = $member->generateAutologinTokenAndStoreHash();
179+
$this->sendEmail($member, $token);
180+
}
181+
182+
return $this->redirectToSuccess($data);
183+
});
194184
}
195185

196186
/**

src/Security/MemberAuthenticator/MemberAuthenticator.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,27 @@ public function supportedServices()
3333

3434
public function authenticate(array $data, HTTPRequest $request, ValidationResult &$result = null)
3535
{
36-
// Find authenticated member
37-
if (class_exists(Versioned::class)) {
38-
[$member, $result] = Versioned::withVersionedMode(function () use ($data) {
39-
Versioned::set_stage(Versioned::DRAFT);
36+
return Security::withMinimumExecutionTime(function () use ($data, $request, &$result) {
37+
// Find authenticated member
38+
if (class_exists(Versioned::class)) {
39+
[$member, $result] = Versioned::withVersionedMode(function () use ($data) {
40+
Versioned::set_stage(Versioned::DRAFT);
41+
$member = $this->authenticateMember($data, $result);
42+
return [$member, $result];
43+
});
44+
} else {
4045
$member = $this->authenticateMember($data, $result);
41-
return [$member, $result];
42-
});
43-
} else {
44-
$member = $this->authenticateMember($data, $result);
45-
}
46+
}
4647

47-
// Optionally record every login attempt as a {@link LoginAttempt} object
48-
$this->recordLoginAttempt($data, $request, $member, $result->isValid());
48+
// Optionally record every login attempt as a {@link LoginAttempt} object
49+
$this->recordLoginAttempt($data, $request, $member, $result->isValid());
4950

50-
if ($member && $request->hasSession()) {
51-
$request->getSession()->clear('BackURL');
52-
}
51+
if ($member && $request->hasSession()) {
52+
$request->getSession()->clear('BackURL');
53+
}
5354

54-
return $result->isValid() ? $member : null;
55+
return $result->isValid() ? $member : null;
56+
});
5557
}
5658

5759
/**

src/Security/Security.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace SilverStripe\Security;
44

5+
use InvalidArgumentException;
56
use LogicException;
67
use Page;
78
use ReflectionClass;
@@ -163,6 +164,12 @@ class Security extends Controller implements TemplateGlobalProvider
163164
*/
164165
private static $login_recording = false;
165166

167+
/**
168+
* Minimum execution time in milliseconds for sensitive execution paths.
169+
* Helps to protect against time-based enumeration attacks.
170+
*/
171+
private static int $secure_min_execution_time = 1000;
172+
166173
/**
167174
* @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
168175
* will always return FALSE. Used for unit testing.
@@ -1211,6 +1218,43 @@ public static function lost_password_url()
12111218
return Controller::join_links(Director::baseURL(), static::config()->get('lost_password_url'));
12121219
}
12131220

1221+
/**
1222+
* Ensure execution of a callback takes some minimum amount of time by inserting a delay if that execution
1223+
* time is not elapsed.
1224+
*
1225+
* This helps to prevent time-based enumeration attacks by making execution of a sensitive code path always
1226+
* take the same amount of time. Note that if $minExecutionTime is too low, the enumeration attack will still
1227+
* be possible - but if it is too high, it could impact the user experience.
1228+
*
1229+
* @param integer $minExecutionTime The minimum amount of time in milliseconds that execution should take.
1230+
* If 0, the secure_min_execution_time configuration property value will be used.
1231+
* @return mixed The value returned from the callback, if any.
1232+
*/
1233+
public static function withMinimumExecutionTime(callable $callback, int $minExecutionTime = 0): mixed
1234+
{
1235+
if ($minExecutionTime < 0) {
1236+
throw new InvalidArgumentException('$minExecutionTime must not be negative');
1237+
}
1238+
// Start capturing execution time
1239+
$startTime = hrtime(true);
1240+
// Execute callback
1241+
$retVal = $callback();
1242+
$stopTime = hrtime(true);
1243+
// Delay by remaining execution time
1244+
if (!$minExecutionTime) {
1245+
$minExecutionTime = Security::config()->get('secure_min_execution_time');
1246+
}
1247+
// $timeTaken gets converted from nanoseconds to microseconds
1248+
// $minExecutionTime gets converted from milliseconds to microseconds
1249+
// $waitFor gets cast to int for use in usleep.
1250+
$timeTaken = ($stopTime - $startTime) / 1000;
1251+
$waitFor = (int) round(($minExecutionTime * 1000) - $timeTaken);
1252+
if ($waitFor > 0) {
1253+
usleep($waitFor);
1254+
}
1255+
return $retVal;
1256+
}
1257+
12141258
/**
12151259
* Defines global accessible templates variables.
12161260
*

tests/php/Core/XssSanitiserTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
class XssSanitiserTest extends SapphireTest
1111
{
12+
// This is the backspace character. It needs to be escaped in double-quotes.
13+
private const CHAR_BACKSPACE = "\x08";
14+
1215
protected $usesDatabase = false;
1316

1417
public function provideSanitise(): array
@@ -73,6 +76,14 @@ public function provideSanitise(): array
7376
'input' => '<a href="javascript:alert(\'ok\')">Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
7477
'expected' => '<a>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
7578
],
79+
[
80+
'input' => '<a href="' . XssSanitiserTest::CHAR_BACKSPACE . 'javascript:alert(\'ok\')">Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
81+
'expected' => '<a>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
82+
],
83+
[
84+
'input' => '<a href="javascript:alert(\'ok\')' . XssSanitiserTest::CHAR_BACKSPACE . '">Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
85+
'expected' => '<a>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</a>',
86+
],
7687
[
7788
// Not exploitable XSS
7889
'input' => '<<a href="javascript:evil"/>a href="javascript:evil"/>',
@@ -132,6 +143,10 @@ public function provideSanitise(): array
132143
'input' => '<IMG SRC="javascript:alert(\'XSS\');">',
133144
'expected' => '<img>',
134145
],
146+
[
147+
'input' => '<IMG SRC="' . XssSanitiserTest::CHAR_BACKSPACE . 'javascript:alert(\'XSS\');">',
148+
'expected' => '<img>',
149+
],
135150
[
136151
'input' => '<IMG SRC=javascript:alert(\'XSS\')>',
137152
'expected' => '<img>',
@@ -206,6 +221,10 @@ public function provideSanitise(): array
206221
'input' => '<BGSOUND SRC="javascript:alert(\'XSS\');">',
207222
'expected' => '<bgsound></bgsound>',
208223
],
224+
[
225+
'input' => '<BGSOUND SRC="' . XssSanitiserTest::CHAR_BACKSPACE . 'javascript:alert(\'XSS\');">',
226+
'expected' => '<bgsound></bgsound>',
227+
],
209228
[
210229
// Not exploitable XSS
211230
'input' => '<BR SIZE="&{alert(\'XSS\')}">',

tests/php/Forms/HTMLEditor/HTMLEditorSanitiserTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
class HTMLEditorSanitiserTest extends FunctionalTest
1212
{
13+
// This is the backspace character. It needs to be escaped in double-quotes.
14+
private const CHAR_BACKSPACE = "\x08";
1315

1416
public function provideSanitise(): array
1517
{
@@ -80,6 +82,18 @@ public function provideSanitise(): array
8082
'<a>Test</a>',
8183
'Javascript in the href attribute of a link is completely removed'
8284
],
85+
[
86+
'a[href|target|rel]',
87+
'<a href="' . HTMLEditorSanitiserTest::CHAR_BACKSPACE . 'javascript:alert(0);">Test</a>',
88+
'<a>Test</a>',
89+
'Javascript in the href attribute with leading backspace of a link is completely removed'
90+
],
91+
[
92+
'a[href|target|rel]',
93+
'<a href="javascript:alert(0);' . HTMLEditorSanitiserTest::CHAR_BACKSPACE . '">Test</a>',
94+
'<a>Test</a>',
95+
'Javascript in the href attribute with backspace in middle of a link is completely removed'
96+
],
8397
[
8498
'a[href|target|rel]',
8599
'<a href="' . implode("\n", str_split(' javascript:')) . '">Test</a>',
@@ -110,6 +124,12 @@ public function provideSanitise(): array
110124
'<iframe></iframe>',
111125
'Javascript with tab elements the src attribute of an iframe is completely removed'
112126
],
127+
[
128+
'iframe[src]',
129+
'<iframe src="' . HTMLEditorSanitiserTest::CHAR_BACKSPACE . 'javascript:alert(0);"></iframe>',
130+
'<iframe></iframe>',
131+
'Javascript in the src attribute of an iframe with a backspace is completely removed'
132+
],
113133
[
114134
'object[data]',
115135
'<object data="OK"></object>',
@@ -128,6 +148,12 @@ public function provideSanitise(): array
128148
'<object></object>',
129149
'Object with dangerous javascript content in data attribute with quotes is completely removed'
130150
],
151+
[
152+
'object[data]',
153+
'<object data="' . HTMLEditorSanitiserTest::CHAR_BACKSPACE . 'javascript:alert()">',
154+
'<object></object>',
155+
'Object with dangerous javascript content in data attribute with backspace is completely removed'
156+
],
131157
[
132158
'object[data]',
133159
'<object data="data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5sb2NhdGlvbik8L3NjcmlwdD4=">',

tests/php/Security/SecurityTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,41 @@ public function doTestChangepasswordForm($oldPassword, $newPassword)
810810
);
811811
}
812812

813+
public function provideWithMinimumExecutionTime(): array
814+
{
815+
return [
816+
'check default is used' => [
817+
'useDefault' => false,
818+
'minExecution' => 100,
819+
],
820+
'check arg is used' => [
821+
'useDefault' => true,
822+
'minExecution' => 100,
823+
],
824+
];
825+
}
826+
827+
/**
828+
* @dataProvider provideWithMinimumExecutionTime
829+
*/
830+
public function testWithMinimumExecutionTime(bool $useDefault, int $minExecution): void
831+
{
832+
if ($useDefault) {
833+
Security::config()->set('secure_min_execution_time', $minExecution);
834+
$minExecutionArg = 0;
835+
} else {
836+
Security::config()->set('secure_min_execution_time', 1);
837+
$minExecutionArg = $minExecution;
838+
}
839+
840+
$start = hrtime(true);
841+
Security::withMinimumExecutionTime(fn() => null, $minExecutionArg);
842+
$timeTaken = hrtime(true) - $start;
843+
$this->assertGreaterThanOrEqual($minExecution, $timeTaken / 1000000);
844+
// Make sure it wasn't much longer than the min - the test empty callback should take basically no time at all.
845+
$this->assertLessThan($minExecution + 1, ($timeTaken / 1000000));
846+
}
847+
813848
/**
814849
* Assert this message is in the current login form errors
815850
*

0 commit comments

Comments
 (0)