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