Skip to content

Commit 1bf0d82

Browse files
committed
code improvements fixing PR comments from @markstory
* fix sprintf not required and extra exception when unexpected debug token found * sort unlocked fields * fix docblocks and sprintf usage * ensure key 0 exists in array * throw exception on _validatePost instead of return false * fix no need to restore the debug value * test what if the tampered field is mutated into an array, improve expected fields output to display correctly the name of the field
1 parent 9b8e149 commit 1bf0d82

File tree

2 files changed

+140
-46
lines changed

2 files changed

+140
-46
lines changed

src/Controller/Component/SecurityComponent.php

Lines changed: 108 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
class SecurityComponent extends Component
4040
{
4141

42+
/**
43+
* Default message used for exceptions thrown
44+
*/
45+
const DEFAULT_EXCEPTION_MESSAGE = 'The request has been black-holed';
46+
4247
/**
4348
* Default config
4449
*
@@ -119,11 +124,10 @@ public function startup(Event $event)
119124
}
120125

121126
if (!in_array($this->_action, (array)$this->_config['unlockedActions']) &&
122-
$hasData && $isNotRequestAction
123-
) {
124-
if ($this->_config['validatePost']) {
127+
$hasData &&
128+
$isNotRequestAction &&
129+
$this->_config['validatePost']) {
125130
$this->_validatePost($controller);
126-
}
127131
}
128132
} catch (SecurityException $se) {
129133
$this->blackHole($controller, $se->getType(), $se);
@@ -203,15 +207,14 @@ public function blackHole(Controller $controller, $error = '', SecurityException
203207
*/
204208
protected function _throwException($exception = null)
205209
{
206-
$defaultMessage = 'The request has been black-holed';
207210
if ($exception !== null) {
208-
if (!Configure::read('debug')) {
211+
if (!Configure::read('debug') && $exception instanceof SecurityException) {
209212
$exception->setReason($exception->getMessage());
210-
$exception->setMessage($defaultMessage);
213+
$exception->setMessage(self::DEFAULT_EXCEPTION_MESSAGE);
211214
}
212215
throw $exception;
213216
}
214-
throw new BadRequestException($defaultMessage);
217+
throw new BadRequestException(self::DEFAULT_EXCEPTION_MESSAGE);
215218
}
216219

217220
/**
@@ -270,7 +273,7 @@ protected function _authRequired(Controller $controller)
270273

271274
if (in_array($this->request->params['action'], $requireAuth) || $requireAuth == ['*']) {
272275
if (!isset($controller->request->data['_Token'])) {
273-
throw new AuthSecurityException(sprintf('\'%s\' was not found in request data.', '_Token'));
276+
throw new AuthSecurityException('\'_Token\' was not found in request data.');
274277
}
275278

276279
if ($this->session->check('_Token')) {
@@ -299,7 +302,7 @@ protected function _authRequired(Controller $controller)
299302
);
300303
}
301304
} else {
302-
throw new AuthSecurityException(sprintf('\'%s\' was not found in session.', '_Token'));
305+
throw new AuthSecurityException('\'_Token\' was not found in session.');
303306
}
304307
}
305308
}
@@ -326,12 +329,12 @@ protected function _validatePost(Controller $controller)
326329
return true;
327330
}
328331

332+
$msg = self::DEFAULT_EXCEPTION_MESSAGE;
329333
if (Configure::read('debug')) {
330334
$msg = $this->_debugPostTokenNotMatching($controller, $hashParts);
331-
throw new SecurityException($msg);
332335
}
333336

334-
return false;
337+
throw new SecurityException($msg);
335338
}
336339

337340
/**
@@ -358,10 +361,13 @@ protected function _validToken(Controller $controller)
358361
if (Configure::read('debug') && !isset($check['_Token']['debug'])) {
359362
throw new SecurityException(sprintf($message, '_Token.debug'));
360363
}
364+
if (!Configure::read('debug') && isset($check['_Token']['debug'])) {
365+
throw new SecurityException('Unexpected \'_Token.debug\' found in request data');
366+
}
361367

362368
$token = urldecode($check['_Token']['fields']);
363369
if (strpos($token, ':')) {
364-
list($token, $locked) = explode(':', $token, 2);
370+
list($token, ) = explode(':', $token, 2);
365371
}
366372
return $token;
367373
}
@@ -375,7 +381,8 @@ protected function _validToken(Controller $controller)
375381
protected function _hashParts(Controller $controller)
376382
{
377383
$fieldList = $this->_fieldsList($controller->request->data);
378-
$unlocked = $this->_unlocked($controller->request->data);
384+
$unlocked = $this->_sortedUnlocked($controller->request->data);
385+
379386
return [
380387
$controller->request->here(),
381388
serialize($fieldList),
@@ -456,20 +463,34 @@ protected function _fieldsList(array $check)
456463
/**
457464
* Get the unlocked string
458465
*
459-
* @param array $check Data array
466+
* @param array $data Data array
467+
* @return string
468+
*/
469+
protected function _unlocked(array $data)
470+
{
471+
return urldecode($data['_Token']['unlocked']);
472+
}
473+
474+
/**
475+
* Get the sorted unlocked string
476+
*
477+
* @param array $data Data array
460478
* @return string
461479
*/
462-
protected function _unlocked(array $check)
480+
protected function _sortedUnlocked($data)
463481
{
464-
return urldecode($check['_Token']['unlocked']);
482+
$unlocked = $this->_unlocked($data);
483+
$unlocked = explode('|', $unlocked);
484+
sort($unlocked, SORT_STRING);
485+
return implode('|', $unlocked);
465486
}
466487

467488
/**
468489
* Create a message for humans to understand why Security token is not matching
469490
*
470491
* @param \Cake\Controller\Controller $controller Instantiating controller
471492
* @param array $hashParts Elements used to generate the Token hash
472-
* @return array Messages to explain why token is not matching
493+
* @return string Message explaining why the tokens are not matching
473494
*/
474495
protected function _debugPostTokenNotMatching(Controller $controller, $hashParts)
475496
{
@@ -478,11 +499,16 @@ protected function _debugPostTokenNotMatching(Controller $controller, $hashParts
478499
if (!is_array($expectedParts) || count($expectedParts) !== 3) {
479500
return 'Invalid security debug token.';
480501
}
481-
if ($hashParts[0] !== $expectedParts[0]) {
482-
$messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedParts[0], $hashParts[0]);
502+
$expectedUrl = Hash::get($expectedParts, 0);
503+
$url = Hash::get($hashParts, 0);
504+
if ($expectedUrl !== $url) {
505+
$messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedUrl, $url);
506+
}
507+
$expectedFields = Hash::get($expectedParts, 1);
508+
$dataFields = Hash::get($hashParts, 1);
509+
if ($dataFields) {
510+
$dataFields = unserialize($dataFields);
483511
}
484-
$expectedFields = $expectedParts[1];
485-
$dataFields = unserialize($hashParts[1]);
486512
$fieldsMessages = $this->_debugCheckFields(
487513
$dataFields,
488514
$expectedFields,
@@ -519,24 +545,10 @@ protected function _debugPostTokenNotMatching(Controller $controller, $hashParts
519545
*/
520546
protected function _debugCheckFields($dataFields, $expectedFields = [], $intKeyMessage = '', $stringKeyMessage = '', $missingMessage = '')
521547
{
522-
$messages = [];
523-
foreach ($dataFields as $key => $value) {
524-
if (is_int($key)) {
525-
$foundKey = array_search($value, (array)$expectedFields);
526-
if ($foundKey === false) {
527-
$messages[] = sprintf($intKeyMessage, $value);
528-
} else {
529-
unset($expectedFields[$foundKey]);
530-
}
531-
} elseif (is_string($key)) {
532-
if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
533-
$messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
534-
}
535-
unset($expectedFields[$key]);
536-
}
537-
}
538-
if (count($expectedFields) > 0) {
539-
$messages[] = sprintf($missingMessage, implode(', ', $expectedFields));
548+
$messages = $this->_matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage);
549+
$expectedFieldsMessage = $this->_debugExpectedFields($expectedFields, $missingMessage);
550+
if ($expectedFieldsMessage !== null) {
551+
$messages[] = $expectedFieldsMessage;
540552
}
541553
return $messages;
542554
}
@@ -585,4 +597,60 @@ protected function _callback(Controller $controller, $method, $params = [])
585597
}
586598
return call_user_func_array([&$controller, $method], empty($params) ? null : $params);
587599
}
600+
601+
/**
602+
* Generate array of messages for the existing fields in POST data, matching dataFields in $expectedFields
603+
* will be unset
604+
*
605+
* @param array $dataFields Fields array, containing the POST data fields
606+
* @param array $expectedFields Fields array, containing the expected fields we should have in POST
607+
* @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected)
608+
* @param string $stringKeyMessage Message string if tampered found in data fields indexed by string (protected)
609+
* @return array Error messages
610+
*/
611+
protected function _matchExistingFields($dataFields, &$expectedFields, $intKeyMessage, $stringKeyMessage)
612+
{
613+
$messages = [];
614+
foreach ((array)$dataFields as $key => $value) {
615+
if (is_int($key)) {
616+
$foundKey = array_search($value, (array)$expectedFields);
617+
if ($foundKey === false) {
618+
$messages[] = sprintf($intKeyMessage, $value);
619+
} else {
620+
unset($expectedFields[$foundKey]);
621+
}
622+
} elseif (is_string($key)) {
623+
if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
624+
$messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
625+
}
626+
unset($expectedFields[$key]);
627+
}
628+
}
629+
630+
return $messages;
631+
}
632+
633+
/**
634+
* Generate debug message for the expected fields
635+
*
636+
* @param array $expectedFields Expected fields
637+
* @param string $missingMessage Message template
638+
* @return string Error message about expected fields
639+
*/
640+
protected function _debugExpectedFields($expectedFields = [], $missingMessage = '')
641+
{
642+
if (count($expectedFields) === 0) {
643+
return null;
644+
}
645+
646+
$expectedFieldNames = [];
647+
foreach ((array)$expectedFields as $key => $expectedField) {
648+
if (is_int($key)) {
649+
$expectedFieldNames[] = $expectedField;
650+
} else {
651+
$expectedFieldNames[] = $key;
652+
}
653+
}
654+
return sprintf($missingMessage, implode(', ', $expectedFieldNames));
655+
}
588656
}

tests/TestCase/Controller/Component/SecurityComponentTest.php

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -959,11 +959,8 @@ public function testValidatePostFailNoDebugMode()
959959
],
960960
'_Token' => compact('fields', 'unlocked')
961961
];
962-
$debug = Configure::read('debug');
963962
Configure::write('debug', false);
964-
$result = $this->validatePost();
965-
$this->assertFalse($result);
966-
Configure::write('debug', $debug);
963+
$result = $this->validatePost('SecurityException', 'The request has been black-holed');
967964
}
968965

969966
/**
@@ -1425,7 +1422,6 @@ public function testBlackholeThrowsException()
14251422
public function testBlackholeThrowsBadRequest()
14261423
{
14271424
$this->Security->config('blackHoleCallback', '');
1428-
$debug = Configure::read('debug');
14291425
$message = '';
14301426

14311427
Configure::write('debug', false);
@@ -1435,7 +1431,6 @@ public function testBlackholeThrowsBadRequest()
14351431
$message = $ex->getMessage();
14361432
$reason = $ex->getReason();
14371433
}
1438-
Configure::write('debug', $debug);
14391434
$this->assertEquals('The request has been black-holed', $message);
14401435
$this->assertEquals('error description', $reason);
14411436
}
@@ -1471,6 +1466,37 @@ public function testValidatePostFailTampering()
14711466
$this->assertFalse($result);
14721467
}
14731468

1469+
/**
1470+
* Test that validatePost fails with tampered fields and explanation
1471+
*
1472+
* @return void
1473+
* @triggers Controller.startup $this->Controller
1474+
*/
1475+
public function testValidatePostFailTamperingMutatedIntoArray()
1476+
{
1477+
$event = new Event('Controller.startup', $this->Controller);
1478+
$this->Controller->Security->startup($event);
1479+
$unlocked = '';
1480+
$fields = ['Model.hidden' => 'value', 'Model.id' => '1'];
1481+
$debug = urlencode(json_encode([
1482+
'/articles/index',
1483+
$fields,
1484+
[]
1485+
]));
1486+
$fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::salt()));
1487+
$fields .= urlencode(':Model.hidden|Model.id');
1488+
$this->Controller->request->data = [
1489+
'Model' => [
1490+
'hidden' => ['some-key' => 'some-value'],
1491+
'id' => '1',
1492+
],
1493+
'_Token' => compact('fields', 'unlocked', 'debug')
1494+
];
1495+
1496+
$result = $this->validatePost('SecurityException', 'Unexpected field \'Model.hidden.some-key\' in POST data, Missing field \'Model.hidden\' in POST data');
1497+
$this->assertFalse($result);
1498+
}
1499+
14741500
/**
14751501
* Auth required throws exception token not found
14761502
*

0 commit comments

Comments
 (0)