1: <?php
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
18:
19: App::uses('Component', 'Controller');
20: App::uses('CakeText', 'Utility');
21: App::uses('Hash', 'Utility');
22: App::uses('Security', 'Utility');
23:
24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36:
37: class SecurityComponent extends Component {
38:
39: 40: 41:
42: const DEFAULT_EXCEPTION_MESSAGE = 'The request has been black-holed';
43:
44: 45: 46: 47: 48:
49: public $blackHoleCallback = null;
50:
51: 52: 53: 54: 55: 56: 57:
58: public $requirePost = array();
59:
60: 61: 62: 63: 64: 65: 66:
67: public $requireGet = array();
68:
69: 70: 71: 72: 73: 74: 75:
76: public $requirePut = array();
77:
78: 79: 80: 81: 82: 83: 84:
85: public $requireDelete = array();
86:
87: 88: 89: 90: 91: 92:
93: public $requireSecure = array();
94:
95: 96: 97: 98: 99: 100: 101:
102: public $requireAuth = array();
103:
104: 105: 106: 107: 108: 109: 110:
111: public $allowedControllers = array();
112:
113: 114: 115: 116: 117: 118: 119:
120: public $allowedActions = array();
121:
122: 123: 124: 125: 126: 127: 128:
129: public $disabledFields = array();
130:
131: 132: 133: 134: 135: 136: 137: 138:
139: public $unlockedFields = array();
140:
141: 142: 143: 144: 145: 146: 147:
148: public $unlockedActions = array();
149:
150: 151: 152: 153: 154: 155:
156: public $validatePost = true;
157:
158: 159: 160: 161: 162: 163: 164:
165: public $csrfCheck = true;
166:
167: 168: 169: 170: 171: 172: 173:
174: public $csrfExpires = '+30 minutes';
175:
176: 177: 178: 179: 180: 181: 182: 183:
184: public $csrfUseOnce = true;
185:
186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196:
197: public $csrfLimit = 100;
198:
199: 200: 201: 202: 203:
204: public $components = array('Session');
205:
206: 207: 208: 209: 210:
211: protected $_action = null;
212:
213: 214: 215: 216: 217:
218: public $request;
219:
220: 221: 222: 223: 224: 225: 226:
227: public function startup(Controller $controller) {
228: $this->request = $controller->request;
229: $this->_action = $controller->request->params['action'];
230: $hasData = ($controller->request->data || $controller->request->is(array('put', 'post', 'delete', 'patch')));
231: try {
232: $this->_methodsRequired($controller);
233: $this->_secureRequired($controller);
234: $this->_authRequired($controller);
235:
236: $isNotRequestAction = (
237: !isset($controller->request->params['requested']) ||
238: $controller->request->params['requested'] != 1
239: );
240:
241: if ($this->_action === $this->blackHoleCallback) {
242: throw new AuthSecurityException(sprintf('Action %s is defined as the blackhole callback.', $this->_action));
243: }
244:
245: if (!in_array($this->_action, (array)$this->unlockedActions) && $hasData && $isNotRequestAction) {
246: if ($this->validatePost) {
247: $this->_validatePost($controller);
248: }
249: if ($this->csrfCheck) {
250: $this->_validateCsrf($controller);
251: }
252: }
253:
254: } catch (SecurityException $se) {
255: return $this->blackHole($controller, $se->getType(), $se);
256: }
257:
258: $this->generateToken($controller->request);
259: if ($hasData && is_array($controller->request->data)) {
260: unset($controller->request->data['_Token']);
261: }
262: }
263:
264: 265: 266: 267: 268: 269: 270:
271: public function requirePost() {
272: $args = func_get_args();
273: $this->_requireMethod('Post', $args);
274: }
275:
276: 277: 278: 279: 280: 281:
282: public function requireGet() {
283: $args = func_get_args();
284: $this->_requireMethod('Get', $args);
285: }
286:
287: 288: 289: 290: 291: 292:
293: public function requirePut() {
294: $args = func_get_args();
295: $this->_requireMethod('Put', $args);
296: }
297:
298: 299: 300: 301: 302: 303:
304: public function requireDelete() {
305: $args = func_get_args();
306: $this->_requireMethod('Delete', $args);
307: }
308:
309: 310: 311: 312: 313: 314:
315: public function requireSecure() {
316: $args = func_get_args();
317: $this->_requireMethod('Secure', $args);
318: }
319:
320: 321: 322: 323: 324: 325: 326: 327: 328: 329:
330: public function requireAuth() {
331: $args = func_get_args();
332: $this->_requireMethod('Auth', $args);
333: }
334:
335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346:
347: public function blackHole(Controller $controller, $error = '', SecurityException $exception = null) {
348: if (!$this->blackHoleCallback) {
349: $this->_throwException($exception);
350: }
351: return $this->_callback($controller, $this->blackHoleCallback, array($error));
352: }
353:
354: 355: 356: 357: 358: 359: 360:
361: protected function _throwException($exception = null) {
362: if ($exception !== null) {
363: if (!Configure::read('debug') && $exception instanceof SecurityException) {
364: $exception->setReason($exception->getMessage());
365: $exception->setMessage(self::DEFAULT_EXCEPTION_MESSAGE);
366: }
367: throw $exception;
368: }
369: throw new BadRequestException(self::DEFAULT_EXCEPTION_MESSAGE);
370: }
371:
372: 373: 374: 375: 376: 377: 378:
379: protected function _requireMethod($method, $actions = array()) {
380: if (isset($actions[0]) && is_array($actions[0])) {
381: $actions = $actions[0];
382: }
383: $this->{'require' . $method} = (empty($actions)) ? array('*') : $actions;
384: }
385:
386: 387: 388: 389: 390: 391: 392:
393: protected function _methodsRequired(Controller $controller) {
394: foreach (array('Post', 'Get', 'Put', 'Delete') as $method) {
395: $property = 'require' . $method;
396: if (is_array($this->$property) && !empty($this->$property)) {
397: $require = $this->$property;
398: if (in_array($this->_action, $require) || $this->$property === array('*')) {
399: if (!$controller->request->is($method)) {
400: throw new SecurityException(
401: sprintf('The request method must be %s', strtoupper($method))
402: );
403: }
404: }
405: }
406: }
407: return true;
408: }
409:
410: 411: 412: 413: 414: 415: 416:
417: protected function _secureRequired(Controller $controller) {
418: if (is_array($this->requireSecure) && !empty($this->requireSecure)) {
419: $requireSecure = $this->requireSecure;
420:
421: if (in_array($this->_action, $requireSecure) || $this->requireSecure === array('*')) {
422: if (!$controller->request->is('ssl')) {
423: throw new SecurityException(
424: 'Request is not SSL and the action is required to be secure'
425: );
426: }
427: }
428: }
429: return true;
430: }
431:
432: 433: 434: 435: 436: 437: 438: 439:
440: protected function _authRequired(Controller $controller) {
441: if (is_array($this->requireAuth) && !empty($this->requireAuth) && !empty($controller->request->data)) {
442: $requireAuth = $this->requireAuth;
443:
444: if (in_array($controller->request->params['action'], $requireAuth) || $this->requireAuth === array('*')) {
445: if (!isset($controller->request->data['_Token'])) {
446: throw new AuthSecurityException('\'_Token\' was not found in request data.');
447: }
448:
449: if ($this->Session->check('_Token')) {
450: $tData = $this->Session->read('_Token');
451:
452: if (!empty($tData['allowedControllers']) &&
453: !in_array($controller->request->params['controller'], $tData['allowedControllers'])) {
454: throw new AuthSecurityException(
455: sprintf(
456: 'Controller \'%s\' was not found in allowed controllers: \'%s\'.',
457: $controller->request->params['controller'],
458: implode(', ', (array)$tData['allowedControllers'])
459: )
460: );
461: }
462: if (!empty($tData['allowedActions']) &&
463: !in_array($controller->request->params['action'], $tData['allowedActions'])
464: ) {
465: throw new AuthSecurityException(
466: sprintf(
467: 'Action \'%s::%s\' was not found in allowed actions: \'%s\'.',
468: $controller->request->params['controller'],
469: $controller->request->params['action'],
470: implode(', ', (array)$tData['allowedActions'])
471: )
472: );
473: }
474: } else {
475: throw new AuthSecurityException('\'_Token\' was not found in session.');
476: }
477: }
478: }
479: return true;
480: }
481:
482: 483: 484: 485: 486: 487: 488:
489: protected function _validatePost(Controller $controller) {
490: $token = $this->_validToken($controller);
491: $hashParts = $this->_hashParts($controller);
492: $check = Security::hash(implode('', $hashParts), 'sha1');
493:
494: if ($token === $check) {
495: return true;
496: }
497:
498: $msg = self::DEFAULT_EXCEPTION_MESSAGE;
499: if (Configure::read('debug')) {
500: $msg = $this->_debugPostTokenNotMatching($controller, $hashParts);
501: }
502:
503: throw new AuthSecurityException($msg);
504: }
505:
506: 507: 508: 509: 510: 511: 512: 513:
514: protected function _validToken(Controller $controller) {
515: $check = $controller->request->data;
516:
517: $message = '\'%s\' was not found in request data.';
518: if (!isset($check['_Token'])) {
519: throw new AuthSecurityException(sprintf($message, '_Token'));
520: }
521: if (!isset($check['_Token']['fields'])) {
522: throw new AuthSecurityException(sprintf($message, '_Token.fields'));
523: }
524: if (!isset($check['_Token']['unlocked'])) {
525: throw new AuthSecurityException(sprintf($message, '_Token.unlocked'));
526: }
527: if (Configure::read('debug') && !isset($check['_Token']['debug'])) {
528: throw new SecurityException(sprintf($message, '_Token.debug'));
529: }
530: if (!Configure::read('debug') && isset($check['_Token']['debug'])) {
531: throw new SecurityException('Unexpected \'_Token.debug\' found in request data');
532: }
533:
534: $token = urldecode($check['_Token']['fields']);
535: if (strpos($token, ':')) {
536: list($token, ) = explode(':', $token, 2);
537: }
538:
539: return $token;
540: }
541:
542: 543: 544: 545: 546: 547:
548: protected function _hashParts(Controller $controller) {
549: $fieldList = $this->_fieldsList($controller->request->data);
550: $unlocked = $this->_sortedUnlocked($controller->request->data);
551:
552: return array(
553: $controller->request->here(),
554: serialize($fieldList),
555: $unlocked,
556: Configure::read('Security.salt')
557: );
558: }
559:
560: 561: 562: 563: 564: 565:
566: protected function _fieldsList(array $check) {
567: $locked = '';
568: $token = urldecode($check['_Token']['fields']);
569: $unlocked = $this->_unlocked($check);
570:
571: if (strpos($token, ':')) {
572: list($token, $locked) = explode(':', $token, 2);
573: }
574: unset($check['_Token'], $check['_csrfToken']);
575:
576: $locked = explode('|', $locked);
577: $unlocked = explode('|', $unlocked);
578:
579: $fields = Hash::flatten($check);
580: $fieldList = array_keys($fields);
581: $multi = $lockedFields = array();
582: $isUnlocked = false;
583:
584: foreach ($fieldList as $i => $key) {
585: if (preg_match('/(\.\d+){1,10}$/', $key)) {
586: $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key);
587: unset($fieldList[$i]);
588: } else {
589: $fieldList[$i] = (string)$key;
590: }
591: }
592: if (!empty($multi)) {
593: $fieldList += array_unique($multi);
594: }
595:
596: $unlockedFields = array_unique(
597: array_merge((array)$this->disabledFields, (array)$this->unlockedFields, $unlocked)
598: );
599:
600: foreach ($fieldList as $i => $key) {
601: $isLocked = (is_array($locked) && in_array($key, $locked));
602:
603: if (!empty($unlockedFields)) {
604: foreach ($unlockedFields as $off) {
605: $off = explode('.', $off);
606: $field = array_values(array_intersect(explode('.', $key), $off));
607: $isUnlocked = ($field === $off);
608: if ($isUnlocked) {
609: break;
610: }
611: }
612: }
613:
614: if ($isUnlocked || $isLocked) {
615: unset($fieldList[$i]);
616: if ($isLocked) {
617: $lockedFields[$key] = $fields[$key];
618: }
619: }
620: }
621: sort($fieldList, SORT_STRING);
622: ksort($lockedFields, SORT_STRING);
623: $fieldList += $lockedFields;
624:
625: return $fieldList;
626: }
627:
628: 629: 630: 631: 632: 633:
634: protected function _unlocked(array $data) {
635: return urldecode($data['_Token']['unlocked']);
636: }
637:
638: 639: 640: 641: 642: 643:
644: protected function _sortedUnlocked($data) {
645: $unlocked = $this->_unlocked($data);
646: $unlocked = explode('|', $unlocked);
647: sort($unlocked, SORT_STRING);
648:
649: return implode('|', $unlocked);
650: }
651:
652: 653: 654: 655: 656: 657: 658:
659: protected function _debugPostTokenNotMatching(Controller $controller, $hashParts) {
660: $messages = array();
661: $expectedParts = json_decode(urldecode($controller->request->data['_Token']['debug']), true);
662: if (!is_array($expectedParts) || count($expectedParts) !== 3) {
663: return 'Invalid security debug token.';
664: }
665: $expectedUrl = Hash::get($expectedParts, 0);
666: $url = Hash::get($hashParts, 0);
667: if ($expectedUrl !== $url) {
668: $messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedUrl, $url);
669: }
670: $expectedFields = Hash::get($expectedParts, 1);
671: $dataFields = Hash::get($hashParts, 1);
672: if ($dataFields) {
673: $dataFields = unserialize($dataFields);
674: }
675: $fieldsMessages = $this->_debugCheckFields(
676: $dataFields,
677: $expectedFields,
678: 'Unexpected field \'%s\' in POST data',
679: 'Tampered field \'%s\' in POST data (expected value \'%s\' but found \'%s\')',
680: 'Missing field \'%s\' in POST data'
681: );
682: $expectedUnlockedFields = Hash::get($expectedParts, 2);
683: $dataUnlockedFields = Hash::get($hashParts, 2) ?: array();
684: if ($dataUnlockedFields) {
685: $dataUnlockedFields = explode('|', $dataUnlockedFields);
686: }
687: $unlockFieldsMessages = $this->_debugCheckFields(
688: $dataUnlockedFields,
689: $expectedUnlockedFields,
690: 'Unexpected unlocked field \'%s\' in POST data',
691: null,
692: 'Missing unlocked field: \'%s\''
693: );
694:
695: $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages);
696:
697: return implode(', ', $messages);
698: }
699:
700: 701: 702: 703: 704: 705: 706: 707: 708: 709:
710: protected function _debugCheckFields($dataFields, $expectedFields = array(), $intKeyMessage = '', $stringKeyMessage = '', $missingMessage = '') {
711: $messages = $this->_matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage);
712: $expectedFieldsMessage = $this->_debugExpectedFields($expectedFields, $missingMessage);
713: if ($expectedFieldsMessage !== null) {
714: $messages[] = $expectedFieldsMessage;
715: }
716:
717: return $messages;
718: }
719:
720: 721: 722: 723: 724: 725:
726: public function generateToken(CakeRequest $request) {
727: if (isset($request->params['requested']) && $request->params['requested'] === 1) {
728: if ($this->Session->check('_Token')) {
729: $request->params['_Token'] = $this->Session->read('_Token');
730: }
731: return false;
732: }
733: $authKey = hash('sha512', Security::randomBytes(16), false);
734: $token = array(
735: 'key' => $authKey,
736: 'allowedControllers' => $this->allowedControllers,
737: 'allowedActions' => $this->allowedActions,
738: 'unlockedFields' => array_merge($this->disabledFields, $this->unlockedFields),
739: 'csrfTokens' => array()
740: );
741:
742: $tokenData = array();
743: if ($this->Session->check('_Token')) {
744: $tokenData = $this->Session->read('_Token');
745: if (!empty($tokenData['csrfTokens']) && is_array($tokenData['csrfTokens'])) {
746: $token['csrfTokens'] = $this->_expireTokens($tokenData['csrfTokens']);
747: }
748: }
749: if ($this->csrfUseOnce || empty($token['csrfTokens'])) {
750: $token['csrfTokens'][$authKey] = strtotime($this->csrfExpires);
751: }
752: if (!$this->csrfUseOnce) {
753: $csrfTokens = array_keys($token['csrfTokens']);
754: $authKey = $csrfTokens[0];
755: $token['key'] = $authKey;
756: $token['csrfTokens'][$authKey] = strtotime($this->csrfExpires);
757: }
758: $this->Session->write('_Token', $token);
759: $request->params['_Token'] = array(
760: 'key' => $token['key'],
761: 'unlockedFields' => $token['unlockedFields']
762: );
763: return true;
764: }
765:
766: 767: 768: 769: 770: 771: 772: 773: 774:
775: protected function _validateCsrf(Controller $controller) {
776: $token = $this->Session->read('_Token');
777: $requestToken = $controller->request->data('_Token.key');
778:
779: if (!$requestToken) {
780: throw new SecurityException('Missing CSRF token');
781: }
782:
783: if (!isset($token['csrfTokens'][$requestToken])) {
784: throw new SecurityException('CSRF token mismatch');
785: }
786:
787: if ($token['csrfTokens'][$requestToken] < time()) {
788: throw new SecurityException('CSRF token expired');
789: }
790:
791: if ($this->csrfUseOnce) {
792: $this->Session->delete('_Token.csrfTokens.' . $requestToken);
793: }
794: return true;
795: }
796:
797: 798: 799: 800: 801: 802: 803:
804: protected function _expireTokens($tokens) {
805: $now = time();
806: foreach ($tokens as $nonce => $expires) {
807: if ($expires < $now) {
808: unset($tokens[$nonce]);
809: }
810: }
811: $overflow = count($tokens) - $this->csrfLimit;
812: if ($overflow > 0) {
813: $tokens = array_slice($tokens, $overflow + 1, null, true);
814: }
815: return $tokens;
816: }
817:
818: 819: 820: 821: 822: 823: 824: 825: 826:
827: protected function _callback(Controller $controller, $method, $params = array()) {
828: if (!is_callable(array($controller, $method))) {
829: throw new BadRequestException(__d('cake_dev', 'The request has been black-holed'));
830: }
831: return call_user_func_array(array(&$controller, $method), empty($params) ? null : $params);
832: }
833:
834: 835: 836: 837: 838: 839: 840: 841: 842: 843:
844: protected function _matchExistingFields($dataFields, &$expectedFields, $intKeyMessage, $stringKeyMessage) {
845: $messages = array();
846: foreach ((array)$dataFields as $key => $value) {
847: if (is_int($key)) {
848: $foundKey = array_search($value, (array)$expectedFields);
849: if ($foundKey === false) {
850: $messages[] = sprintf($intKeyMessage, $value);
851: } else {
852: unset($expectedFields[$foundKey]);
853: }
854: } elseif (is_string($key)) {
855: if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
856: $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
857: }
858: unset($expectedFields[$key]);
859: }
860: }
861:
862: return $messages;
863: }
864:
865: 866: 867: 868: 869: 870: 871:
872: protected function _debugExpectedFields($expectedFields = array(), $missingMessage = '') {
873: if (count($expectedFields) === 0) {
874: return null;
875: }
876:
877: $expectedFieldNames = array();
878: foreach ((array)$expectedFields as $key => $expectedField) {
879: if (is_int($key)) {
880: $expectedFieldNames[] = $expectedField;
881: } else {
882: $expectedFieldNames[] = $key;
883: }
884: }
885:
886: return sprintf($missingMessage, implode(', ', $expectedFieldNames));
887: }
888:
889: }
890: