SecurityComponent.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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 CakePHP(tm) v 0.10.8.2156
  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\ComponentRegistry;
  18. use Cake\Controller\Controller;
  19. use Cake\Core\Configure;
  20. use Cake\Error;
  21. use Cake\Event\Event;
  22. use Cake\Network\Request;
  23. use Cake\Utility\Hash;
  24. use Cake\Utility\Security;
  25. /**
  26. * The Security Component creates an easy way to integrate tighter security in
  27. * your application. It provides methods for various tasks like:
  28. *
  29. * - Restricting which HTTP methods your application accepts.
  30. * - CSRF protection.
  31. * - Form tampering protection
  32. * - Requiring that SSL be used.
  33. * - Limiting cross controller communication.
  34. *
  35. * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html
  36. */
  37. class SecurityComponent extends Component {
  38. /**
  39. * The controller method that will be called if this request is black-hole'd
  40. *
  41. * @var string
  42. */
  43. public $blackHoleCallback = null;
  44. /**
  45. * List of actions that require an SSL-secured connection
  46. *
  47. * @var array
  48. * @see SecurityComponent::requireSecure()
  49. */
  50. public $requireSecure = array();
  51. /**
  52. * List of actions that require a valid authentication key
  53. *
  54. * @var array
  55. * @see SecurityComponent::requireAuth()
  56. */
  57. public $requireAuth = array();
  58. /**
  59. * Controllers from which actions of the current controller are allowed to receive
  60. * requests.
  61. *
  62. * @var array
  63. * @see SecurityComponent::requireAuth()
  64. */
  65. public $allowedControllers = array();
  66. /**
  67. * Actions from which actions of the current controller are allowed to receive
  68. * requests.
  69. *
  70. * @var array
  71. * @see SecurityComponent::requireAuth()
  72. */
  73. public $allowedActions = array();
  74. /**
  75. * Form fields to exclude from POST validation. Fields can be unlocked
  76. * either in the Component, or with FormHelper::unlockField().
  77. * Fields that have been unlocked are not required to be part of the POST
  78. * and hidden unlocked fields do not have their values checked.
  79. *
  80. * @var array
  81. */
  82. public $unlockedFields = array();
  83. /**
  84. * Actions to exclude from CSRF and POST validation checks.
  85. * Other checks like requireAuth(), requireSecure(),
  86. * requirePost(), requireGet() etc. will still be applied.
  87. *
  88. * @var array
  89. */
  90. public $unlockedActions = array();
  91. /**
  92. * Whether to validate POST data. Set to false to disable for data coming from 3rd party
  93. * services, etc.
  94. *
  95. * @var boolean
  96. */
  97. public $validatePost = true;
  98. /**
  99. * Whether to use CSRF protected forms. Set to false to disable CSRF protection on forms.
  100. *
  101. * @var boolean
  102. * @see http://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)
  103. * @see SecurityComponent::$csrfExpires
  104. */
  105. public $csrfCheck = true;
  106. /**
  107. * The duration from when a CSRF token is created that it will expire on.
  108. * Each form/page request will generate a new token that can only be submitted once unless
  109. * it expires. Can be any value compatible with strtotime()
  110. *
  111. * @var string
  112. */
  113. public $csrfExpires = '+30 minutes';
  114. /**
  115. * Controls whether or not CSRF tokens are use and burn. Set to false to not generate
  116. * new tokens on each request. One token will be reused until it expires. This reduces
  117. * the chances of users getting invalid requests because of token consumption.
  118. * It has the side effect of making CSRF less secure, as tokens are reusable.
  119. *
  120. * @var boolean
  121. */
  122. public $csrfUseOnce = true;
  123. /**
  124. * Control the number of tokens a user can keep open.
  125. * This is most useful with one-time use tokens. Since new tokens
  126. * are created on each request, having a hard limit on the number of open tokens
  127. * can be useful in controlling the size of the session file.
  128. *
  129. * When tokens are evicted, the oldest ones will be removed, as they are the most likely
  130. * to be dead/expired.
  131. *
  132. * @var integer
  133. */
  134. public $csrfLimit = 100;
  135. /**
  136. * Other components used by the Security component
  137. *
  138. * @var array
  139. */
  140. public $components = array('Session');
  141. /**
  142. * Holds the current action of the controller
  143. *
  144. * @var string
  145. */
  146. protected $_action = null;
  147. /**
  148. * Request object
  149. *
  150. * @var Cake\Network\Request
  151. */
  152. public $request;
  153. /**
  154. * Component startup. All security checking happens here.
  155. *
  156. * @param Event $event An Event instance
  157. * @return void
  158. */
  159. public function startup(Event $event) {
  160. $controller = $event->subject();
  161. $this->request = $controller->request;
  162. $this->_action = $this->request->params['action'];
  163. $this->_secureRequired($controller);
  164. $this->_authRequired($controller);
  165. $isPost = $this->request->is(array('post', 'put'));
  166. $isNotRequestAction = (
  167. !isset($controller->request->params['requested']) ||
  168. $controller->request->params['requested'] != 1
  169. );
  170. if ($this->_action == $this->blackHoleCallback) {
  171. return $this->blackHole($controller, 'auth');
  172. }
  173. if (!in_array($this->_action, (array)$this->unlockedActions) && $isPost && $isNotRequestAction) {
  174. if ($this->validatePost && $this->_validatePost($controller) === false) {
  175. return $this->blackHole($controller, 'auth');
  176. }
  177. if ($this->csrfCheck && $this->_validateCsrf($controller) === false) {
  178. return $this->blackHole($controller, 'csrf');
  179. }
  180. }
  181. $this->generateToken($controller->request);
  182. if ($isPost && is_array($controller->request->data)) {
  183. unset($controller->request->data['_Token']);
  184. }
  185. }
  186. /**
  187. * Sets the actions that require a request that is SSL-secured, or empty for all actions
  188. *
  189. * @return void
  190. * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#SecurityComponent::requireSecure
  191. */
  192. public function requireSecure() {
  193. $args = func_get_args();
  194. $this->_requireMethod('Secure', $args);
  195. }
  196. /**
  197. * Sets the actions that require whitelisted form submissions.
  198. *
  199. * Adding actions with this method will enforce the restrictions
  200. * set in SecurityComponent::$allowedControllers and
  201. * SecurityComponent::$allowedActions.
  202. *
  203. * @return void
  204. * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#SecurityComponent::requireAuth
  205. */
  206. public function requireAuth() {
  207. $args = func_get_args();
  208. $this->_requireMethod('Auth', $args);
  209. }
  210. /**
  211. * Black-hole an invalid request with a 400 error or custom callback. If SecurityComponent::$blackHoleCallback
  212. * is specified, it will use this callback by executing the method indicated in $error
  213. *
  214. * @param Controller $controller Instantiating controller
  215. * @param string $error Error method
  216. * @return mixed If specified, controller blackHoleCallback's response, or no return otherwise
  217. * @see SecurityComponent::$blackHoleCallback
  218. * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#handling-blackhole-callbacks
  219. * @throws Cake\Error\BadRequestException
  220. */
  221. public function blackHole(Controller $controller, $error = '') {
  222. if (!$this->blackHoleCallback) {
  223. throw new Error\BadRequestException(__d('cake_dev', 'The request has been black-holed'));
  224. }
  225. return $this->_callback($controller, $this->blackHoleCallback, array($error));
  226. }
  227. /**
  228. * Sets the actions that require a $method HTTP request, or empty for all actions
  229. *
  230. * @param string $method The HTTP method to assign controller actions to
  231. * @param array $actions Controller actions to set the required HTTP method to.
  232. * @return void
  233. */
  234. protected function _requireMethod($method, $actions = array()) {
  235. if (isset($actions[0]) && is_array($actions[0])) {
  236. $actions = $actions[0];
  237. }
  238. $this->{'require' . $method} = (empty($actions)) ? array('*') : $actions;
  239. }
  240. /**
  241. * Check if access requires secure connection
  242. *
  243. * @param Controller $controller Instantiating controller
  244. * @return boolean true if secure connection required
  245. */
  246. protected function _secureRequired(Controller $controller) {
  247. if (is_array($this->requireSecure) && !empty($this->requireSecure)) {
  248. $requireSecure = $this->requireSecure;
  249. if (in_array($this->_action, $requireSecure) || $this->requireSecure == array('*')) {
  250. if (!$this->request->is('ssl')) {
  251. if (!$this->blackHole($controller, 'secure')) {
  252. return null;
  253. }
  254. }
  255. }
  256. }
  257. return true;
  258. }
  259. /**
  260. * Check if authentication is required
  261. *
  262. * @param Controller $controller Instantiating controller
  263. * @return boolean true if authentication required
  264. */
  265. protected function _authRequired(Controller $controller) {
  266. if (is_array($this->requireAuth) && !empty($this->requireAuth) && !empty($this->request->data)) {
  267. $requireAuth = $this->requireAuth;
  268. if (in_array($this->request->params['action'], $requireAuth) || $this->requireAuth == array('*')) {
  269. if (!isset($controller->request->data['_Token'])) {
  270. if (!$this->blackHole($controller, 'auth')) {
  271. return null;
  272. }
  273. }
  274. if ($this->Session->check('_Token')) {
  275. $tData = $this->Session->read('_Token');
  276. if (
  277. !empty($tData['allowedControllers']) &&
  278. !in_array($this->request->params['controller'], $tData['allowedControllers']) ||
  279. !empty($tData['allowedActions']) &&
  280. !in_array($this->request->params['action'], $tData['allowedActions'])
  281. ) {
  282. if (!$this->blackHole($controller, 'auth')) {
  283. return null;
  284. }
  285. }
  286. } else {
  287. if (!$this->blackHole($controller, 'auth')) {
  288. return null;
  289. }
  290. }
  291. }
  292. }
  293. return true;
  294. }
  295. /**
  296. * Validate submitted form
  297. *
  298. * @param Controller $controller Instantiating controller
  299. * @return boolean true if submitted form is valid
  300. */
  301. protected function _validatePost(Controller $controller) {
  302. if (empty($controller->request->data)) {
  303. return true;
  304. }
  305. $data = $controller->request->data;
  306. if (!isset($data['_Token']) || !isset($data['_Token']['fields']) || !isset($data['_Token']['unlocked'])) {
  307. return false;
  308. }
  309. $locked = '';
  310. $check = $controller->request->data;
  311. $token = urldecode($check['_Token']['fields']);
  312. $unlocked = urldecode($check['_Token']['unlocked']);
  313. if (strpos($token, ':')) {
  314. list($token, $locked) = explode(':', $token, 2);
  315. }
  316. unset($check['_Token']);
  317. $locked = explode('|', $locked);
  318. $unlocked = explode('|', $unlocked);
  319. $lockedFields = array();
  320. $fields = Hash::flatten($check);
  321. $fieldList = array_keys($fields);
  322. $multi = array();
  323. foreach ($fieldList as $i => $key) {
  324. if (preg_match('/(\.\d+)+$/', $key)) {
  325. $multi[$i] = preg_replace('/(\.\d+)+$/', '', $key);
  326. unset($fieldList[$i]);
  327. }
  328. }
  329. if (!empty($multi)) {
  330. $fieldList += array_unique($multi);
  331. }
  332. $unlockedFields = array_unique(
  333. array_merge((array)$this->disabledFields, (array)$this->unlockedFields, $unlocked)
  334. );
  335. foreach ($fieldList as $i => $key) {
  336. $isLocked = (is_array($locked) && in_array($key, $locked));
  337. if (!empty($unlockedFields)) {
  338. foreach ($unlockedFields as $off) {
  339. $off = explode('.', $off);
  340. $field = array_values(array_intersect(explode('.', $key), $off));
  341. $isUnlocked = ($field === $off);
  342. if ($isUnlocked) {
  343. break;
  344. }
  345. }
  346. }
  347. if ($isUnlocked || $isLocked) {
  348. unset($fieldList[$i]);
  349. if ($isLocked) {
  350. $lockedFields[$key] = $fields[$key];
  351. }
  352. }
  353. }
  354. sort($unlocked, SORT_STRING);
  355. sort($fieldList, SORT_STRING);
  356. ksort($lockedFields, SORT_STRING);
  357. $fieldList += $lockedFields;
  358. $unlocked = implode('|', $unlocked);
  359. $check = Security::hash(serialize($fieldList) . $unlocked . Configure::read('Security.salt'), 'sha1');
  360. return ($token === $check);
  361. }
  362. /**
  363. * Manually add CSRF token information into the provided request object.
  364. *
  365. * @param Cake\Network\Request $request The request object to add into.
  366. * @return boolean
  367. */
  368. public function generateToken(Request $request) {
  369. if (isset($request->params['requested']) && $request->params['requested'] === 1) {
  370. if ($this->Session->check('_Token')) {
  371. $request->params['_Token'] = $this->Session->read('_Token');
  372. }
  373. return false;
  374. }
  375. $authKey = Security::generateAuthKey();
  376. $token = array(
  377. 'key' => $authKey,
  378. 'allowedControllers' => $this->allowedControllers,
  379. 'allowedActions' => $this->allowedActions,
  380. 'unlockedFields' => $this->unlockedFields,
  381. 'csrfTokens' => array()
  382. );
  383. $tokenData = array();
  384. if ($this->Session->check('_Token')) {
  385. $tokenData = $this->Session->read('_Token');
  386. if (!empty($tokenData['csrfTokens']) && is_array($tokenData['csrfTokens'])) {
  387. $token['csrfTokens'] = $this->_expireTokens($tokenData['csrfTokens']);
  388. }
  389. }
  390. if ($this->csrfUseOnce || empty($token['csrfTokens'])) {
  391. $token['csrfTokens'][$authKey] = strtotime($this->csrfExpires);
  392. }
  393. if (!$this->csrfUseOnce) {
  394. $csrfTokens = array_keys($token['csrfTokens']);
  395. $token['key'] = $csrfTokens[0];
  396. }
  397. $this->Session->write('_Token', $token);
  398. $request->params['_Token'] = array(
  399. 'key' => $token['key'],
  400. 'unlockedFields' => $token['unlockedFields']
  401. );
  402. return true;
  403. }
  404. /**
  405. * Validate that the controller has a CSRF token in the POST data
  406. * and that the token is legit/not expired. If the token is valid
  407. * it will be removed from the list of valid tokens.
  408. *
  409. * @param Controller $controller A controller to check
  410. * @return boolean Valid csrf token.
  411. */
  412. protected function _validateCsrf(Controller $controller) {
  413. $token = $this->Session->read('_Token');
  414. $requestToken = $controller->request->data('_Token.key');
  415. if (isset($token['csrfTokens'][$requestToken]) && $token['csrfTokens'][$requestToken] >= time()) {
  416. if ($this->csrfUseOnce) {
  417. $this->Session->delete('_Token.csrfTokens.' . $requestToken);
  418. }
  419. return true;
  420. }
  421. return false;
  422. }
  423. /**
  424. * Expire CSRF nonces and remove them from the valid tokens.
  425. * Uses a simple timeout to expire the tokens.
  426. *
  427. * @param array $tokens An array of nonce => expires.
  428. * @return array An array of nonce => expires.
  429. */
  430. protected function _expireTokens($tokens) {
  431. $now = time();
  432. foreach ($tokens as $nonce => $expires) {
  433. if ($expires < $now) {
  434. unset($tokens[$nonce]);
  435. }
  436. }
  437. $overflow = count($tokens) - $this->csrfLimit;
  438. if ($overflow > 0) {
  439. $tokens = array_slice($tokens, $overflow + 1, null, true);
  440. }
  441. return $tokens;
  442. }
  443. /**
  444. * Calls a controller callback method
  445. *
  446. * @param Controller $controller Controller to run callback on
  447. * @param string $method Method to execute
  448. * @param array $params Parameters to send to method
  449. * @return mixed Controller callback method's response
  450. * @throws Cake\Error\BadRequestException When a the blackholeCallback is not callable.
  451. */
  452. protected function _callback(Controller $controller, $method, $params = array()) {
  453. if (!is_callable(array($controller, $method))) {
  454. throw new Error\BadRequestException(__d('cake_dev', 'The request has been black-holed'));
  455. }
  456. return call_user_func_array(array(&$controller, $method), empty($params) ? null : $params);
  457. }
  458. }