1: <?php
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
15: namespace Cake\Controller\Component;
16:
17: use Cake\Controller\Component;
18: use Cake\Controller\Controller;
19: use Cake\Controller\Exception\AuthSecurityException;
20: use Cake\Controller\Exception\SecurityException;
21: use Cake\Core\Configure;
22: use Cake\Event\Event;
23: use Cake\Http\Exception\BadRequestException;
24: use Cake\Http\ServerRequest;
25: use Cake\Routing\Router;
26: use Cake\Utility\Hash;
27: use Cake\Utility\Security;
28:
29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39:
40: class SecurityComponent extends Component
41: {
42:
43: 44: 45:
46: const DEFAULT_EXCEPTION_MESSAGE = 'The request has been black-holed';
47:
48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69:
70: protected $_defaultConfig = [
71: 'blackHoleCallback' => null,
72: 'requireSecure' => [],
73: 'requireAuth' => [],
74: 'allowedControllers' => [],
75: 'allowedActions' => [],
76: 'unlockedFields' => [],
77: 'unlockedActions' => [],
78: 'validatePost' => true
79: ];
80:
81: 82: 83: 84: 85:
86: protected $_action;
87:
88: 89: 90: 91: 92:
93: public $session;
94:
95: 96: 97: 98: 99: 100:
101: public function startup(Event $event)
102: {
103:
104: $controller = $event->getSubject();
105: $request = $controller->request;
106: $this->session = $request->getSession();
107: $this->_action = $request->getParam('action');
108: $hasData = ($request->getData() || $request->is(['put', 'post', 'delete', 'patch']));
109: try {
110: $this->_secureRequired($controller);
111: $this->_authRequired($controller);
112:
113: $isNotRequestAction = !$request->getParam('requested');
114:
115: if ($this->_action === $this->_config['blackHoleCallback']) {
116: throw new AuthSecurityException(sprintf('Action %s is defined as the blackhole callback.', $this->_action));
117: }
118:
119: if (!in_array($this->_action, (array)$this->_config['unlockedActions']) &&
120: $hasData &&
121: $isNotRequestAction &&
122: $this->_config['validatePost']
123: ) {
124: $this->_validatePost($controller);
125: }
126: } catch (SecurityException $se) {
127: return $this->blackHole($controller, $se->getType(), $se);
128: }
129:
130: $request = $this->generateToken($request);
131: if ($hasData && is_array($controller->getRequest()->getData())) {
132: $request = $request->withoutData('_Token');
133: }
134: $controller->setRequest($request);
135: }
136:
137: 138: 139: 140: 141:
142: public function implementedEvents()
143: {
144: return [
145: 'Controller.startup' => 'startup',
146: ];
147: }
148:
149: 150: 151: 152: 153: 154:
155: public function requireSecure($actions = null)
156: {
157: $this->_requireMethod('Secure', (array)$actions);
158: }
159:
160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170:
171: public function requireAuth($actions)
172: {
173: deprecationWarning('SecurityComponent::requireAuth() will be removed in 4.0.0.');
174: $this->_requireMethod('Auth', (array)$actions);
175: }
176:
177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188:
189: public function blackHole(Controller $controller, $error = '', SecurityException $exception = null)
190: {
191: if (!$this->_config['blackHoleCallback']) {
192: $this->_throwException($exception);
193: }
194:
195: return $this->_callback($controller, $this->_config['blackHoleCallback'], [$error, $exception]);
196: }
197:
198: 199: 200: 201: 202: 203: 204:
205: protected function _throwException($exception = null)
206: {
207: if ($exception !== null) {
208: if (!Configure::read('debug') && $exception instanceof SecurityException) {
209: $exception->setReason($exception->getMessage());
210: $exception->setMessage(self::DEFAULT_EXCEPTION_MESSAGE);
211: }
212: throw $exception;
213: }
214: throw new BadRequestException(self::DEFAULT_EXCEPTION_MESSAGE);
215: }
216:
217: 218: 219: 220: 221: 222: 223:
224: protected function _requireMethod($method, $actions = [])
225: {
226: if (isset($actions[0]) && is_array($actions[0])) {
227: $actions = $actions[0];
228: }
229: $this->setConfig('require' . $method, empty($actions) ? ['*'] : $actions);
230: }
231:
232: 233: 234: 235: 236: 237:
238: protected function _secureRequired(Controller $controller)
239: {
240: if (is_array($this->_config['requireSecure']) &&
241: !empty($this->_config['requireSecure'])
242: ) {
243: $requireSecure = $this->_config['requireSecure'];
244:
245: if (in_array($this->_action, $requireSecure) || $requireSecure === ['*']) {
246: if (!$this->getController()->getRequest()->is('ssl')) {
247: throw new SecurityException(
248: 'Request is not SSL and the action is required to be secure'
249: );
250: }
251: }
252: }
253:
254: return true;
255: }
256:
257: 258: 259: 260: 261: 262: 263:
264: protected function _authRequired(Controller $controller)
265: {
266: $request = $controller->getRequest();
267: if (is_array($this->_config['requireAuth']) &&
268: !empty($this->_config['requireAuth']) &&
269: $request->getData()
270: ) {
271: deprecationWarning('SecurityComponent::requireAuth() will be removed in 4.0.0.');
272: $requireAuth = $this->_config['requireAuth'];
273:
274: if (in_array($request->getParam('action'), $requireAuth) || $requireAuth == ['*']) {
275: if ($request->getData('_Token') === null) {
276: throw new AuthSecurityException('\'_Token\' was not found in request data.');
277: }
278:
279: if ($this->session->check('_Token')) {
280: $tData = $this->session->read('_Token');
281:
282: if (!empty($tData['allowedControllers']) &&
283: !in_array($request->getParam('controller'), $tData['allowedControllers'])) {
284: throw new AuthSecurityException(
285: sprintf(
286: 'Controller \'%s\' was not found in allowed controllers: \'%s\'.',
287: $request->getParam('controller'),
288: implode(', ', (array)$tData['allowedControllers'])
289: )
290: );
291: }
292: if (!empty($tData['allowedActions']) &&
293: !in_array($request->getParam('action'), $tData['allowedActions'])
294: ) {
295: throw new AuthSecurityException(
296: sprintf(
297: 'Action \'%s::%s\' was not found in allowed actions: \'%s\'.',
298: $request->getParam('controller'),
299: $request->getParam('action'),
300: implode(', ', (array)$tData['allowedActions'])
301: )
302: );
303: }
304: } else {
305: throw new AuthSecurityException('\'_Token\' was not found in session.');
306: }
307: }
308: }
309:
310: return true;
311: }
312:
313: 314: 315: 316: 317: 318: 319:
320: protected function _validatePost(Controller $controller)
321: {
322: $token = $this->_validToken($controller);
323: $hashParts = $this->_hashParts($controller);
324: $check = hash_hmac('sha1', implode('', $hashParts), Security::getSalt());
325:
326: if (hash_equals($check, $token)) {
327: return true;
328: }
329:
330: $msg = self::DEFAULT_EXCEPTION_MESSAGE;
331: if (Configure::read('debug')) {
332: $msg = $this->_debugPostTokenNotMatching($controller, $hashParts);
333: }
334:
335: throw new AuthSecurityException($msg);
336: }
337:
338: 339: 340: 341: 342: 343: 344:
345: protected function _validToken(Controller $controller)
346: {
347: $check = $controller->getRequest()->getData();
348:
349: $message = '\'%s\' was not found in request data.';
350: if (!isset($check['_Token'])) {
351: throw new AuthSecurityException(sprintf($message, '_Token'));
352: }
353: if (!isset($check['_Token']['fields'])) {
354: throw new AuthSecurityException(sprintf($message, '_Token.fields'));
355: }
356: if (!isset($check['_Token']['unlocked'])) {
357: throw new AuthSecurityException(sprintf($message, '_Token.unlocked'));
358: }
359: if (Configure::read('debug') && !isset($check['_Token']['debug'])) {
360: throw new SecurityException(sprintf($message, '_Token.debug'));
361: }
362: if (!Configure::read('debug') && isset($check['_Token']['debug'])) {
363: throw new SecurityException('Unexpected \'_Token.debug\' found in request data');
364: }
365:
366: $token = urldecode($check['_Token']['fields']);
367: if (strpos($token, ':')) {
368: list($token, ) = explode(':', $token, 2);
369: }
370:
371: return $token;
372: }
373:
374: 375: 376: 377: 378: 379:
380: protected function _hashParts(Controller $controller)
381: {
382: $request = $controller->getRequest();
383:
384:
385: $session = $request->getSession();
386: $session->start();
387:
388: $data = $request->getData();
389: $fieldList = $this->_fieldsList($data);
390: $unlocked = $this->_sortedUnlocked($data);
391:
392: return [
393: Router::url($request->getRequestTarget()),
394: serialize($fieldList),
395: $unlocked,
396: $session->id()
397: ];
398: }
399:
400: 401: 402: 403: 404: 405:
406: protected function _fieldsList(array $check)
407: {
408: $locked = '';
409: $token = urldecode($check['_Token']['fields']);
410: $unlocked = $this->_unlocked($check);
411:
412: if (strpos($token, ':')) {
413: list($token, $locked) = explode(':', $token, 2);
414: }
415: unset($check['_Token'], $check['_csrfToken']);
416:
417: $locked = explode('|', $locked);
418: $unlocked = explode('|', $unlocked);
419:
420: $fields = Hash::flatten($check);
421: $fieldList = array_keys($fields);
422: $multi = $lockedFields = [];
423: $isUnlocked = false;
424:
425: foreach ($fieldList as $i => $key) {
426: if (preg_match('/(\.\d+){1,10}$/', $key)) {
427: $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key);
428: unset($fieldList[$i]);
429: } else {
430: $fieldList[$i] = (string)$key;
431: }
432: }
433: if (!empty($multi)) {
434: $fieldList += array_unique($multi);
435: }
436:
437: $unlockedFields = array_unique(
438: array_merge((array)$this->getConfig('disabledFields'), (array)$this->_config['unlockedFields'], $unlocked)
439: );
440:
441: foreach ($fieldList as $i => $key) {
442: $isLocked = (is_array($locked) && in_array($key, $locked));
443:
444: if (!empty($unlockedFields)) {
445: foreach ($unlockedFields as $off) {
446: $off = explode('.', $off);
447: $field = array_values(array_intersect(explode('.', $key), $off));
448: $isUnlocked = ($field === $off);
449: if ($isUnlocked) {
450: break;
451: }
452: }
453: }
454:
455: if ($isUnlocked || $isLocked) {
456: unset($fieldList[$i]);
457: if ($isLocked) {
458: $lockedFields[$key] = $fields[$key];
459: }
460: }
461: }
462: sort($fieldList, SORT_STRING);
463: ksort($lockedFields, SORT_STRING);
464: $fieldList += $lockedFields;
465:
466: return $fieldList;
467: }
468:
469: 470: 471: 472: 473: 474:
475: protected function _unlocked(array $data)
476: {
477: return urldecode($data['_Token']['unlocked']);
478: }
479:
480: 481: 482: 483: 484: 485:
486: protected function _sortedUnlocked($data)
487: {
488: $unlocked = $this->_unlocked($data);
489: $unlocked = explode('|', $unlocked);
490: sort($unlocked, SORT_STRING);
491:
492: return implode('|', $unlocked);
493: }
494:
495: 496: 497: 498: 499: 500: 501:
502: protected function _debugPostTokenNotMatching(Controller $controller, $hashParts)
503: {
504: $messages = [];
505: $expectedParts = json_decode(urldecode($controller->getRequest()->getData('_Token.debug')), true);
506: if (!is_array($expectedParts) || count($expectedParts) !== 3) {
507: return 'Invalid security debug token.';
508: }
509: $expectedUrl = Hash::get($expectedParts, 0);
510: $url = Hash::get($hashParts, 0);
511: if ($expectedUrl !== $url) {
512: $messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedUrl, $url);
513: }
514: $expectedFields = Hash::get($expectedParts, 1);
515: $dataFields = Hash::get($hashParts, 1);
516: if ($dataFields) {
517: $dataFields = unserialize($dataFields);
518: }
519: $fieldsMessages = $this->_debugCheckFields(
520: $dataFields,
521: $expectedFields,
522: 'Unexpected field \'%s\' in POST data',
523: 'Tampered field \'%s\' in POST data (expected value \'%s\' but found \'%s\')',
524: 'Missing field \'%s\' in POST data'
525: );
526: $expectedUnlockedFields = Hash::get($expectedParts, 2);
527: $dataUnlockedFields = Hash::get($hashParts, 2) ?: null;
528: if ($dataUnlockedFields) {
529: $dataUnlockedFields = explode('|', $dataUnlockedFields);
530: }
531: $unlockFieldsMessages = $this->_debugCheckFields(
532: (array)$dataUnlockedFields,
533: $expectedUnlockedFields,
534: 'Unexpected unlocked field \'%s\' in POST data',
535: null,
536: 'Missing unlocked field: \'%s\''
537: );
538:
539: $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages);
540:
541: return implode(', ', $messages);
542: }
543:
544: 545: 546: 547: 548: 549: 550: 551: 552: 553:
554: protected function _debugCheckFields($dataFields, $expectedFields = [], $intKeyMessage = '', $stringKeyMessage = '', $missingMessage = '')
555: {
556: $messages = $this->_matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage);
557: $expectedFieldsMessage = $this->_debugExpectedFields($expectedFields, $missingMessage);
558: if ($expectedFieldsMessage !== null) {
559: $messages[] = $expectedFieldsMessage;
560: }
561:
562: return $messages;
563: }
564:
565: 566: 567: 568: 569: 570: 571:
572: public function generateToken(ServerRequest $request)
573: {
574: if ($request->is('requested')) {
575: if ($this->session->check('_Token')) {
576: $request = $request->withParam('_Token', $this->session->read('_Token'));
577: }
578:
579: return $request;
580: }
581: $token = [
582: 'allowedControllers' => $this->_config['allowedControllers'],
583: 'allowedActions' => $this->_config['allowedActions'],
584: 'unlockedFields' => $this->_config['unlockedFields'],
585: ];
586:
587: $this->session->write('_Token', $token);
588:
589: return $request->withParam('_Token', [
590: 'unlockedFields' => $token['unlockedFields']
591: ]);
592: }
593:
594: 595: 596: 597: 598: 599: 600: 601: 602:
603: protected function _callback(Controller $controller, $method, $params = [])
604: {
605: if (!is_callable([$controller, $method])) {
606: throw new BadRequestException('The request has been black-holed');
607: }
608:
609: return call_user_func_array([&$controller, $method], empty($params) ? null : $params);
610: }
611:
612: 613: 614: 615: 616: 617: 618: 619: 620: 621:
622: protected function _matchExistingFields($dataFields, &$expectedFields, $intKeyMessage, $stringKeyMessage)
623: {
624: $messages = [];
625: foreach ((array)$dataFields as $key => $value) {
626: if (is_int($key)) {
627: $foundKey = array_search($value, (array)$expectedFields);
628: if ($foundKey === false) {
629: $messages[] = sprintf($intKeyMessage, $value);
630: } else {
631: unset($expectedFields[$foundKey]);
632: }
633: } elseif (is_string($key)) {
634: if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) {
635: $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value);
636: }
637: unset($expectedFields[$key]);
638: }
639: }
640:
641: return $messages;
642: }
643:
644: 645: 646: 647: 648: 649: 650:
651: protected function _debugExpectedFields($expectedFields = [], $missingMessage = '')
652: {
653: if (count($expectedFields) === 0) {
654: return null;
655: }
656:
657: $expectedFieldNames = [];
658: foreach ((array)$expectedFields as $key => $expectedField) {
659: if (is_int($key)) {
660: $expectedFieldNames[] = $expectedField;
661: } else {
662: $expectedFieldNames[] = $key;
663: }
664: }
665:
666: return sprintf($missingMessage, implode(', ', $expectedFieldNames));
667: }
668: }
669: