SecurityComponent.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 0.10.8
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Controller\Component;
  16. use Cake\Controller\Component;
  17. use Cake\Controller\Controller;
  18. use Cake\Controller\Exception\AuthSecurityException;
  19. use Cake\Controller\Exception\SecurityException;
  20. use Cake\Core\Configure;
  21. use Cake\Event\Event;
  22. use Cake\Http\ServerRequest;
  23. use Cake\Network\Exception\BadRequestException;
  24. use Cake\Utility\Hash;
  25. use Cake\Utility\Security;
  26. /**
  27. * The Security Component creates an easy way to integrate tighter security in
  28. * your application. It provides methods for various tasks like:
  29. *
  30. * - Restricting which HTTP methods your application accepts.
  31. * - Form tampering protection
  32. * - Requiring that SSL be used.
  33. * - Limiting cross controller communication.
  34. *
  35. * @link http://book.cakephp.org/3.0/en/controllers/components/security.html
  36. */
  37. class SecurityComponent extends Component
  38. {
  39. /**
  40. * Default message used for exceptions thrown
  41. */
  42. const DEFAULT_EXCEPTION_MESSAGE = 'The request has been black-holed';
  43. /**
  44. * Default config
  45. *
  46. * - `blackHoleCallback` - The controller method that will be called if this
  47. * request is black-hole'd.
  48. * - `requireSecure` - List of actions that require an SSL-secured connection.
  49. * - `requireAuth` - List of actions that require a valid authentication key. Deprecated as of 3.2.2
  50. * - `allowedControllers` - Controllers from which actions of the current
  51. * controller are allowed to receive requests.
  52. * - `allowedActions` - Actions from which actions of the current controller
  53. * are allowed to receive requests.
  54. * - `unlockedFields` - Form fields to exclude from POST validation. Fields can
  55. * be unlocked either in the Component, or with FormHelper::unlockField().
  56. * Fields that have been unlocked are not required to be part of the POST
  57. * and hidden unlocked fields do not have their values checked.
  58. * - `unlockedActions` - Actions to exclude from POST validation checks.
  59. * Other checks like requireAuth(), requireSecure() etc. will still be applied.
  60. * - `validatePost` - Whether to validate POST data. Set to false to disable
  61. * for data coming from 3rd party services, etc.
  62. *
  63. * @var array
  64. */
  65. protected $_defaultConfig = [
  66. 'blackHoleCallback' => null,
  67. 'requireSecure' => [],
  68. 'requireAuth' => [],
  69. 'allowedControllers' => [],
  70. 'allowedActions' => [],
  71. 'unlockedFields' => [],
  72. 'unlockedActions' => [],
  73. 'validatePost' => true
  74. ];
  75. /**
  76. * Holds the current action of the controller
  77. *
  78. * @var string
  79. */
  80. protected $_action = null;
  81. /**
  82. * The Session object
  83. *
  84. * @var \Cake\Network\Session
  85. */
  86. public $session;
  87. /**
  88. * Component startup. All security checking happens here.
  89. *
  90. * @param \Cake\Event\Event $event An Event instance
  91. * @return mixed
  92. */
  93. public function startup(Event $event)
  94. {
  95. $controller = $event->getSubject();
  96. $this->session = $controller->request->session();
  97. $this->_action = $controller->request->getParam('action');
  98. $hasData = (bool)$controller->request->getData();
  99. try {
  100. $this->_secureRequired($controller);
  101. $this->_authRequired($controller);
  102. $isNotRequestAction = !$controller->request->getParam('requested');
  103. if ($this->_action === $this->_config['blackHoleCallback']) {
  104. throw new AuthSecurityException(sprintf('Action %s is defined as the blackhole callback.', $this->_action));
  105. }
  106. if (!in_array($this->_action, (array)$this->_config['unlockedActions']) &&
  107. $hasData &&
  108. $isNotRequestAction &&
  109. $this->_config['validatePost']) {
  110. $this->_validatePost($controller);
  111. }
  112. } catch (SecurityException $se) {
  113. $this->blackHole($controller, $se->getType(), $se);
  114. }
  115. $this->generateToken($controller->request);
  116. if ($hasData && is_array($controller->request->getData())) {
  117. unset($controller->request->data['_Token']);
  118. }
  119. }
  120. /**
  121. * Events supported by this component.
  122. *
  123. * @return array
  124. */
  125. public function implementedEvents()
  126. {
  127. return [
  128. 'Controller.startup' => 'startup',
  129. ];
  130. }
  131. /**
  132. * Sets the actions that require a request that is SSL-secured, or empty for all actions
  133. *
  134. * @param string|array|null $actions Actions list
  135. * @return void
  136. */
  137. public function requireSecure($actions = null)
  138. {
  139. $this->_requireMethod('Secure', (array)$actions);
  140. }
  141. /**
  142. * Sets the actions that require whitelisted form submissions.
  143. *
  144. * Adding actions with this method will enforce the restrictions
  145. * set in SecurityComponent::$allowedControllers and
  146. * SecurityComponent::$allowedActions.
  147. *
  148. * @param string|array $actions Actions list
  149. * @return void
  150. * @deprecated 3.2.2 This feature is confusing and not useful.
  151. */
  152. public function requireAuth($actions)
  153. {
  154. $this->_requireMethod('Auth', (array)$actions);
  155. }
  156. /**
  157. * Black-hole an invalid request with a 400 error or custom callback. If SecurityComponent::$blackHoleCallback
  158. * is specified, it will use this callback by executing the method indicated in $error
  159. *
  160. * @param \Cake\Controller\Controller $controller Instantiating controller
  161. * @param string $error Error method
  162. * @param \Cake\Controller\Exception\SecurityException|null $exception Additional debug info describing the cause
  163. * @return mixed If specified, controller blackHoleCallback's response, or no return otherwise
  164. * @see \Cake\Controller\Component\SecurityComponent::$blackHoleCallback
  165. * @link http://book.cakephp.org/3.0/en/controllers/components/security.html#handling-blackhole-callbacks
  166. * @throws \Cake\Network\Exception\BadRequestException
  167. */
  168. public function blackHole(Controller $controller, $error = '', SecurityException $exception = null)
  169. {
  170. if (!$this->_config['blackHoleCallback']) {
  171. $this->_throwException($exception);
  172. }
  173. return $this->_callback($controller, $this->_config['blackHoleCallback'], [$error, $exception]);
  174. }
  175. /**
  176. * Check debug status and throw an Exception based on the existing one
  177. *
  178. * @param \Cake\Controller\Exception\SecurityException|null $exception Additional debug info describing the cause
  179. * @throws \Cake\Network\Exception\BadRequestException
  180. * @return void
  181. */
  182. protected function _throwException($exception = null)
  183. {
  184. if ($exception !== null) {
  185. if (!Configure::read('debug') && $exception instanceof SecurityException) {
  186. $exception->setReason($exception->getMessage());
  187. $exception->setMessage(self::DEFAULT_EXCEPTION_MESSAGE);
  188. }
  189. throw $exception;
  190. }
  191. throw new BadRequestException(self::DEFAULT_EXCEPTION_MESSAGE);
  192. }
  193. /**
  194. * Sets the actions that require a $method HTTP request, or empty for all actions
  195. *
  196. * @param string $method The HTTP method to assign controller actions to
  197. * @param array $actions Controller actions to set the required HTTP method to.
  198. * @return void
  199. */
  200. protected function _requireMethod($method, $actions = [])
  201. {
  202. if (isset($actions[0]) && is_array($actions[0])) {
  203. $actions = $actions[0];
  204. }
  205. $this->setConfig('require' . $method, (empty($actions)) ? ['*'] : $actions);
  206. }
  207. /**
  208. * Check if access requires secure connection
  209. *
  210. * @param \Cake\Controller\Controller $controller Instantiating controller
  211. * @return bool true if secure connection required
  212. */
  213. protected function _secureRequired(Controller $controller)
  214. {
  215. if (is_array($this->_config['requireSecure']) &&
  216. !empty($this->_config['requireSecure'])
  217. ) {
  218. $requireSecure = $this->_config['requireSecure'];
  219. if (in_array($this->_action, $requireSecure) || $requireSecure === ['*']) {
  220. if (!$this->request->is('ssl')) {
  221. throw new SecurityException(
  222. 'Request is not SSL and the action is required to be secure'
  223. );
  224. }
  225. }
  226. }
  227. return true;
  228. }
  229. /**
  230. * Check if authentication is required
  231. *
  232. * @param \Cake\Controller\Controller $controller Instantiating controller
  233. * @return bool true if authentication required
  234. * @deprecated 3.2.2 This feature is confusing and not useful.
  235. */
  236. protected function _authRequired(Controller $controller)
  237. {
  238. $request = $controller->request;
  239. if (is_array($this->_config['requireAuth']) &&
  240. !empty($this->_config['requireAuth']) &&
  241. $request->getData()
  242. ) {
  243. $requireAuth = $this->_config['requireAuth'];
  244. if (in_array($request->getParam('action'), $requireAuth) || $requireAuth == ['*']) {
  245. if ($request->getData('_Token') === null) {
  246. throw new AuthSecurityException('\'_Token\' was not found in request data.');
  247. }
  248. if ($this->session->check('_Token')) {
  249. $tData = $this->session->read('_Token');
  250. if (!empty($tData['allowedControllers']) &&
  251. !in_array($request->getParam('controller'), $tData['allowedControllers'])) {
  252. throw new AuthSecurityException(
  253. sprintf(
  254. 'Controller \'%s\' was not found in allowed controllers: \'%s\'.',
  255. $request->getParam('controller'),
  256. implode(', ', (array)$tData['allowedControllers'])
  257. )
  258. );
  259. }
  260. if (!empty($tData['allowedActions']) &&
  261. !in_array($request->getParam('action'), $tData['allowedActions'])
  262. ) {
  263. throw new AuthSecurityException(
  264. sprintf(
  265. 'Action \'%s::%s\' was not found in allowed actions: \'%s\'.',
  266. $request->getParam('controller'),
  267. $request->getParam('action'),
  268. implode(', ', (array)$tData['allowedActions'])
  269. )
  270. );
  271. }
  272. } else {
  273. throw new AuthSecurityException('\'_Token\' was not found in session.');
  274. }
  275. }
  276. }
  277. return true;
  278. }
  279. /**
  280. * Validate submitted form
  281. *
  282. * @param \Cake\Controller\Controller $controller Instantiating controller
  283. * @throws \Cake\Controller\Exception\AuthSecurityException
  284. * @return bool true if submitted form is valid
  285. */
  286. protected function _validatePost(Controller $controller)
  287. {
  288. if (!$controller->request->getData()) {
  289. return true;
  290. }
  291. $token = $this->_validToken($controller);
  292. $hashParts = $this->_hashParts($controller);
  293. $check = Security::hash(implode('', $hashParts), 'sha1');
  294. if ($token === $check) {
  295. return true;
  296. }
  297. $msg = self::DEFAULT_EXCEPTION_MESSAGE;
  298. if (Configure::read('debug')) {
  299. $msg = $this->_debugPostTokenNotMatching($controller, $hashParts);
  300. }
  301. throw new AuthSecurityException($msg);
  302. }
  303. /**
  304. * Check if token is valid
  305. *
  306. * @param \Cake\Controller\Controller $controller Instantiating controller
  307. * @throws \Cake\Controller\Exception\SecurityException
  308. * @return string fields token
  309. */
  310. protected function _validToken(Controller $controller)
  311. {
  312. $check = $controller->request->getData();
  313. $message = '\'%s\' was not found in request data.';
  314. if (!isset($check['_Token'])) {
  315. throw new AuthSecurityException(sprintf($message, '_Token'));
  316. }
  317. if (!isset($check['_Token']['fields'])) {
  318. throw new AuthSecurityException(sprintf($message, '_Token.fields'));
  319. }
  320. if (!isset($check['_Token']['unlocked'])) {
  321. throw new AuthSecurityException(sprintf($message, '_Token.unlocked'));
  322. }
  323. if (Configure::read('debug') && !isset($check['_Token']['debug'])) {
  324. throw new SecurityException(sprintf($message, '_Token.debug'));
  325. }
  326. if (!Configure::read('debug') && isset($check['_Token']['debug'])) {
  327. throw new SecurityException('Unexpected \'_Token.debug\' found in request data');
  328. }
  329. $token = urldecode($check['_Token']['fields']);
  330. if (strpos($token, ':')) {
  331. list($token, ) = explode(':', $token, 2);
  332. }
  333. return $token;
  334. }
  335. /**
  336. * Return hash parts for the Token generation
  337. *
  338. * @param \Cake\Controller\Controller $controller Instantiating controller
  339. * @return array
  340. */
  341. protected function _hashParts(Controller $controller)
  342. {
  343. $fieldList = $this->_fieldsList($controller->request->getData());
  344. $unlocked = $this->_sortedUnlocked($controller->request->getData());
  345. return [
  346. $controller->request->here(),
  347. serialize($fieldList),
  348. $unlocked,
  349. Security::salt()
  350. ];
  351. }
  352. /**
  353. * Return the fields list for the hash calculation
  354. *
  355. * @param array $check Data array
  356. * @return array
  357. */
  358. protected function _fieldsList(array $check)
  359. {
  360. $locked = '';
  361. $token = urldecode($check['_Token']['fields']);
  362. $unlocked = $this->_unlocked($check);
  363. if (strpos($token, ':')) {
  364. list($token, $locked) = explode(':', $token, 2);
  365. }
  366. unset($check['_Token'], $check['_csrfToken']);
  367. $locked = explode('|', $locked);
  368. $unlocked = explode('|', $unlocked);
  369. $fields = Hash::flatten($check);
  370. $fieldList = array_keys($fields);
  371. $multi = $lockedFields = [];
  372. $isUnlocked = false;
  373. foreach ($fieldList as $i => $key) {
  374. if (preg_match('/(\.\d+){1,10}$/', $key)) {
  375. $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key);
  376. unset($fieldList[$i]);
  377. } else {
  378. $fieldList[$i] = (string)$key;
  379. }
  380. }
  381. if (!empty($multi)) {
  382. $fieldList += array_unique($multi);
  383. }
  384. $unlockedFields = array_unique(
  385. array_merge((array)$this->getConfig('disabledFields'), (array)$this->_config['unlockedFields'], $unlocked)
  386. );
  387. foreach ($fieldList as $i => $key) {
  388. $isLocked = (is_array($locked) && in_array($key, $locked));
  389. if (!empty($unlockedFields)) {
  390. foreach ($unlockedFields as $off) {
  391. $off = explode('.', $off);
  392. $field = array_values(array_intersect(explode('.', $key), $off));
  393. $isUnlocked = ($field === $off);
  394. if ($isUnlocked) {
  395. break;
  396. }
  397. }
  398. }
  399. if ($isUnlocked || $isLocked) {
  400. unset($fieldList[$i]);
  401. if ($isLocked) {
  402. $lockedFields[$key] = $fields[$key];
  403. }
  404. }
  405. }
  406. sort($fieldList, SORT_STRING);
  407. ksort($lockedFields, SORT_STRING);
  408. $fieldList += $lockedFields;
  409. return $fieldList;
  410. }
  411. /**
  412. * Get the unlocked string
  413. *
  414. * @param array $data Data array
  415. * @return string
  416. */
  417. protected function _unlocked(array $data)
  418. {
  419. return urldecode($data['_Token']['unlocked']);
  420. }
  421. /**
  422. * Get the sorted unlocked string
  423. *
  424. * @param array $data Data array
  425. * @return string
  426. */
  427. protected function _sortedUnlocked($data)
  428. {
  429. $unlocked = $this->_unlocked($data);
  430. $unlocked = explode('|', $unlocked);
  431. sort($unlocked, SORT_STRING);
  432. return implode('|', $unlocked);
  433. }
  434. /**
  435. * Create a message for humans to understand why Security token is not matching
  436. *
  437. * @param \Cake\Controller\Controller $controller Instantiating controller
  438. * @param array $hashParts Elements used to generate the Token hash
  439. * @return string Message explaining why the tokens are not matching
  440. */
  441. protected function _debugPostTokenNotMatching(Controller $controller, $hashParts)
  442. {
  443. $messages = [];
  444. $expectedParts = json_decode(urldecode($controller->request->getData('_Token.debug')), true);
  445. if (!is_array($expectedParts) || count($expectedParts) !== 3) {
  446. return 'Invalid security debug token.';
  447. }
  448. $expectedUrl = Hash::get($expectedParts, 0);
  449. $url = Hash::get($hashParts, 0);
  450. if ($expectedUrl !== $url) {
  451. $messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedUrl, $url);
  452. }
  453. $expectedFields = Hash::get($expectedParts, 1);
  454. $dataFields = Hash::get($hashParts, 1);
  455. if ($dataFields) {
  456. $dataFields = unserialize($dataFields);
  457. }
  458. $fieldsMessages = $this->_debugCheckFields(
  459. $dataFields,
  460. $expectedFields,
  461. 'Unexpected field \'%s\' in POST data',
  462. 'Tampered field \'%s\' in POST data (expected value \'%s\' but found \'%s\')',
  463. 'Missing field \'%s\' in POST data'
  464. );
  465. $expectedUnlockedFields = Hash::get($expectedParts, 2);
  466. $dataUnlockedFields = Hash::get($hashParts, 2) ?: [];
  467. if ($dataUnlockedFields) {
  468. $dataUnlockedFields = explode('|', $dataUnlockedFields);
  469. }
  470. $unlockFieldsMessages = $this->_debugCheckFields(
  471. $dataUnlockedFields,
  472. $expectedUnlockedFields,
  473. 'Unexpected unlocked field \'%s\' in POST data',
  474. null,
  475. 'Missing unlocked field: \'%s\''
  476. );
  477. $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages);
  478. return implode(', ', $messages);
  479. }
  480. /**
  481. * Iterates data array to check against expected
  482. *
  483. * @param array $dataFields Fields array, containing the POST data fields
  484. * @param array $expectedFields Fields array, containing the expected fields we should have in POST
  485. * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected)
  486. * @param string $stringKeyMessage Message string if tampered found in data fields indexed by string (protected)
  487. * @param string $missingMessage Message string if missing field
  488. * @return array Messages
  489. */
  490. protected function _debugCheckFields($dataFields, $expectedFields = [], $intKeyMessage = '', $stringKeyMessage = '', $missingMessage = '')
  491. {
  492. $messages = $this->_matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage);
  493. $expectedFieldsMessage = $this->_debugExpectedFields($expectedFields, $missingMessage);
  494. if ($expectedFieldsMessage !== null) {
  495. $messages[] = $expectedFieldsMessage;
  496. }
  497. return $messages;
  498. }
  499. /**
  500. * Manually add form tampering prevention token information into the provided
  501. * request object.
  502. *
  503. * @param \Cake\Http\ServerRequest $request The request object to add into.
  504. * @return bool
  505. */
  506. public function generateToken(ServerRequest $request)
  507. {
  508. if ($request->is('requested')) {
  509. if ($this->session->check('_Token')) {
  510. $request->params['_Token'] = $this->session->read('_Token');
  511. }
  512. return false;
  513. }
  514. $token = [
  515. 'allowedControllers' => $this->_config['allowedControllers'],
  516. 'allowedActions' => $this->_config['allowedActions'],
  517. 'unlockedFields' => $this->_config['unlockedFields'],
  518. ];
  519. $this->session->write('_Token', $token);
  520. $request->params['_Token'] = [
  521. 'unlockedFields' => $token['unlockedFields']
  522. ];
  523. return true;
  524. }
  525. /**
  526. * Calls a controller callback method
  527. *
  528. * @param \Cake\Controller\Controller $controller Instantiating controller
  529. * @param string $method Method to execute
  530. * @param array $params Parameters to send to method
  531. * @return mixed Controller callback method's response
  532. * @throws \Cake\Network\Exception\BadRequestException When a the blackholeCallback is not callable.
  533. */
  534. protected function _callback(Controller $controller, $method, $params = [])
  535. {
  536. if (!is_callable([$controller, $method])) {
  537. throw new BadRequestException('The request has been black-holed');
  538. }
  539. return call_user_func_array([&$controller, $method], empty($params) ? null : $params);
  540. }
  541. /**
  542. * Generate array of messages for the existing fields in POST data, matching dataFields in $expectedFields
  543. * will be unset
  544. *
  545. * @param array $dataFields Fields array, containing the POST data fields
  546. * @param array $expectedFields Fields array, containing the expected fields we should have in POST
  547. * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected)
  548. * @param string $stringKeyMessage Message string if tampered found in data fields indexed by string (protected)
  549. * @return array Error messages
  550. */
  551. protected function _matchExistingFields($dataFields, &$expectedFields, $intKeyMessage, $stringKeyMessage)
  552. {
  553. $messages = [];
  554. foreach ((array)$dataFields as $key => $value) {
  555. if (is_int($key)) {
  556. $foundKey = array_search($value, (array)$expectedFields);
  557. if ($foundKey === false) {
  558. $messages[] = sprintf($intKeyMessage, $value);
  559. } else {
  560. unset($expectedFields[$foundKey]);
  561. }
  562. } elseif (is_string($key)) {
  563. if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
  564. $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
  565. }
  566. unset($expectedFields[$key]);
  567. }
  568. }
  569. return $messages;
  570. }
  571. /**
  572. * Generate debug message for the expected fields
  573. *
  574. * @param array $expectedFields Expected fields
  575. * @param string $missingMessage Message template
  576. * @return string|null Error message about expected fields
  577. */
  578. protected function _debugExpectedFields($expectedFields = [], $missingMessage = '')
  579. {
  580. if (count($expectedFields) === 0) {
  581. return null;
  582. }
  583. $expectedFieldNames = [];
  584. foreach ((array)$expectedFields as $key => $expectedField) {
  585. if (is_int($key)) {
  586. $expectedFieldNames[] = $expectedField;
  587. } else {
  588. $expectedFieldNames[] = $key;
  589. }
  590. }
  591. return sprintf($missingMessage, implode(', ', $expectedFieldNames));
  592. }
  593. }