Browse Source

Merge pull request #7524 from cakephp/issue-7004

Make it easier to test csrf & security component protected actions.
Mark Story 10 years ago
parent
commit
0a89ca9fa3

+ 75 - 2
src/TestSuite/IntegrationTestCase.php

@@ -21,6 +21,8 @@ use Cake\Routing\DispatcherFactory;
 use Cake\Routing\Router;
 use Cake\TestSuite\Stub\Response;
 use Cake\Utility\Hash;
+use Cake\Utility\Text;
+use Cake\View\Helper\SecureFieldTokenTrait;
 use Exception;
 use PHPUnit_Exception;
 
@@ -36,6 +38,7 @@ use PHPUnit_Exception;
  */
 abstract class IntegrationTestCase extends TestCase
 {
+    use SecureFieldTokenTrait;
 
     /**
      * The data used to build the next request.
@@ -101,6 +104,22 @@ abstract class IntegrationTestCase extends TestCase
     protected $_requestSession;
 
     /**
+     * Boolean flag for whether or not the request should have
+     * a SecurityComponent token added.
+     *
+     * @var bool
+     */
+    protected $_securityToken = false;
+
+    /**
+     * Boolean flag for whether or not the request should have
+     * a CSRF token added.
+     *
+     * @var bool
+     */
+    protected $_csrfToken = false;
+
+    /**
      * Clears the state used for requests.
      *
      * @return void
@@ -117,6 +136,33 @@ abstract class IntegrationTestCase extends TestCase
         $this->_viewName = null;
         $this->_layoutName = null;
         $this->_requestSession = null;
+        $this->_securityToken = false;
+        $this->_csrfToken = false;
+    }
+
+    /**
+     * Calling this method will enable a SecurityComponent
+     * compatible token to be added to request data. This
+     * lets you easily test actions protected by SecurityComponent.
+     *
+     * @return void
+     */
+    public function enableSecurityToken()
+    {
+        $this->_securityToken = true;
+    }
+
+    /**
+     * Calling this method will add a CSRF token to the request.
+     *
+     * Both the POST data and cookie will be populated when this option
+     * is enabled. The default parameter names will be used.
+     *
+     * @return void
+     */
+    public function enableCsrfToken()
+    {
+        $this->_csrfToken = true;
     }
 
     /**
@@ -343,11 +389,11 @@ abstract class IntegrationTestCase extends TestCase
         ];
         $session = Session::create($sessionConfig);
         $session->write($this->_session);
-
         list ($url, $query) = $this->_url($url);
+
         $props = [
             'url' => $url,
-            'post' => $data,
+            'post' => $this->_addTokens($url, $data),
             'cookies' => $this->_cookie,
             'session' => $session,
             'query' => $query
@@ -369,6 +415,33 @@ abstract class IntegrationTestCase extends TestCase
     }
 
     /**
+     * Add the CSRF and Security Component tokens if necessary.
+     *
+     * @param string $url The URL the form is being submitted on.
+     * @param array $data The request body data.
+     * @return array The request body with tokens added.
+     */
+    protected function _addTokens($url, $data)
+    {
+        if ($this->_securityToken === true) {
+            $keys = Hash::flatten($data);
+            $tokenData = $this->_buildFieldToken($url, $keys);
+            $data['_Token'] = $tokenData;
+        }
+
+        if ($this->_csrfToken === true) {
+            $csrfToken = Text::uuid();
+            if (!isset($data['_csrfToken'])) {
+                $data['_csrfToken'] = $csrfToken;
+            }
+            if (!isset($this->_cookie['csrfToken'])) {
+                $this->_cookie['csrfToken'] = $csrfToken;
+            }
+        }
+        return $data;
+    }
+
+    /**
      * Creates a valid request url and parameter array more like Request::_url()
      *
      * @param string|array $url The URL

+ 8 - 27
src/View/Helper/FormHelper.php

@@ -29,6 +29,7 @@ use Cake\View\Form\EntityContext;
 use Cake\View\Form\FormContext;
 use Cake\View\Form\NullContext;
 use Cake\View\Helper;
+use Cake\View\Helper\SecureFieldTokenTrait;
 use Cake\View\StringTemplateTrait;
 use Cake\View\View;
 use Cake\View\Widget\WidgetRegistry;
@@ -49,6 +50,7 @@ class FormHelper extends Helper
 {
 
     use IdGeneratorTrait;
+    use SecureFieldTokenTrait;
     use StringTemplateTrait;
 
     /**
@@ -554,39 +556,18 @@ class FormHelper extends Helper
             return null;
         }
         $locked = [];
-        $unlockedFields = $this->_unlockedFields;
 
-        foreach ($fields as $key => $value) {
-            if (is_numeric($value)) {
-                $value = (string)$value;
-            }
-            if (!is_int($key)) {
-                $locked[$key] = $value;
-                unset($fields[$key]);
-            }
-        }
-
-        sort($unlockedFields, SORT_STRING);
-        sort($fields, SORT_STRING);
-        ksort($locked, SORT_STRING);
-        $fields += $locked;
-
-        $locked = implode(array_keys($locked), '|');
-        $unlocked = implode($unlockedFields, '|');
-        $hashParts = [
+        $tokenData = $this->_buildFieldToken(
             $this->_lastAction,
-            serialize($fields),
-            $unlocked,
-            Security::salt()
-        ];
-        $fields = Security::hash(implode('', $hashParts), 'sha1');
-
+            $fields,
+            $this->_unlockedFields
+        );
         $tokenFields = array_merge($secureAttributes, [
-            'value' => urlencode($fields . ':' . $locked),
+            'value' => $tokenData['fields'],
         ]);
         $out = $this->hidden('_Token.fields', $tokenFields);
         $tokenUnlocked = array_merge($secureAttributes, [
-            'value' => urlencode($unlocked),
+            'value' => $tokenData['unlocked'],
         ]);
         $out .= $this->hidden('_Token.unlocked', $tokenUnlocked);
         return $this->formatTemplate('hiddenBlock', ['content' => $out]);

+ 68 - 0
src/View/Helper/SecureFieldTokenTrait.php

@@ -0,0 +1,68 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.1.2
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\View\Helper;
+
+use Cake\Utility\Security;
+
+/**
+ * Provides methods for building token data that is
+ * compatible with SecurityComponent.
+ */
+trait SecureFieldTokenTrait
+{
+    /**
+     * Generate the token data for the provided inputs.
+     *
+     * @param string $url The URL the form is being submitted to.
+     * @param array $fields If set specifies the list of fields to use when
+     *    generating the hash.
+     * @param array $unlockedFields The list of fields that are excluded from
+     *    field validation.
+     * @return array The token data.
+     */
+    protected function _buildFieldToken($url, $fields, $unlockedFields = [])
+    {
+        $locked = [];
+        foreach ($fields as $key => $value) {
+            if (is_numeric($value)) {
+                $value = (string)$value;
+            }
+            if (!is_int($key)) {
+                $locked[$key] = $value;
+                unset($fields[$key]);
+            }
+        }
+
+        sort($unlockedFields, SORT_STRING);
+        sort($fields, SORT_STRING);
+        ksort($locked, SORT_STRING);
+        $fields += $locked;
+
+        $locked = implode(array_keys($locked), '|');
+        $unlocked = implode($unlockedFields, '|');
+        $hashParts = [
+            $url,
+            serialize($fields),
+            $unlocked,
+            Security::salt()
+        ];
+        $fields = Security::hash(implode('', $hashParts), 'sha1');
+
+        return [
+            'fields' => urlencode($fields . ':' . $locked),
+            'unlocked' => urlencode($unlocked),
+        ];
+    }
+}

+ 58 - 1
tests/TestCase/TestSuite/IntegrationTestCaseTest.php

@@ -66,13 +66,38 @@ class IntegrationTestCaseTest extends IntegrationTestCase
 
         $this->assertEquals('abc123', $request->header('X-CSRF-Token'));
         $this->assertEquals('tasks/add', $request->url);
-        $this->assertEquals(['split_token' => 'def345'], $request->cookies);
+        $this->assertArrayHasKey('split_token', $request->cookies);
+        $this->assertEquals('def345', $request->cookies['split_token']);
         $this->assertEquals(['id' => '1', 'username' => 'mark'], $request->session()->read('User'));
         $this->assertEquals('foo', $request->env('PHP_AUTH_USER'));
         $this->assertEquals('bar', $request->env('PHP_AUTH_PW'));
     }
 
     /**
+     * Test request building adds csrf tokens
+     *
+     * @return void
+     */
+    public function testRequestBuildingCsrfTokens()
+    {
+        $this->enableCsrfToken();
+        $request = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'First post']);
+
+        $this->assertArrayHasKey('csrfToken', $request->cookies);
+        $this->assertArrayHasKey('_csrfToken', $request->data);
+        $this->assertSame($request->cookies['csrfToken'], $request->data['_csrfToken']);
+
+        $this->cookie('csrfToken', '');
+        $request = $this->_buildRequest('/tasks/add', 'POST', [
+            '_csrfToken' => 'fale',
+            'title' => 'First post'
+        ]);
+
+        $this->assertSame('', $request->cookies['csrfToken']);
+        $this->assertSame('fale', $request->data['_csrfToken']);
+    }
+
+    /**
      * Test building a request, with query parameters
      *
      * @return void
@@ -168,6 +193,38 @@ class IntegrationTestCaseTest extends IntegrationTestCase
     }
 
     /**
+     * Test posting to a secured form action action.
+     *
+     * @return void
+     */
+    public function testPostSecuredForm()
+    {
+        $this->enableSecurityToken();
+        $data = [
+            'title' => 'Some title',
+            'body' => 'Some text'
+        ];
+        $this->post('/posts/securePost', $data);
+        $this->assertResponseOk();
+        $this->assertResponseContains('Request was accepted');
+    }
+
+    /**
+     * Test posting to a secured form action action.
+     *
+     * @return void
+     */
+    public function testPostSecuredFormFailure()
+    {
+        $data = [
+            'title' => 'Some title',
+            'body' => 'Some text'
+        ];
+        $this->post('/posts/securePost', $data);
+        $this->assertResponseError();
+    }
+
+    /**
      * Test that exceptions being thrown are handled correctly.
      *
      * @return void

+ 25 - 1
tests/test_app/TestApp/Controller/PostsController.php

@@ -14,6 +14,7 @@
  */
 namespace TestApp\Controller;
 
+use Cake\Event\Event;
 use TestApp\Controller\AppController;
 
 /**
@@ -22,7 +23,6 @@ use TestApp\Controller\AppController;
  */
 class PostsController extends AppController
 {
-
     /**
      * Components array
      *
@@ -31,9 +31,22 @@ class PostsController extends AppController
     public $components = [
         'Flash',
         'RequestHandler',
+        'Security',
     ];
 
     /**
+     * beforeFilter
+     *
+     * @return void
+     */
+    public function beforeFilter(Event $event)
+    {
+        if ($this->request->param('action') !== 'securePost') {
+            $this->eventManager()->off($this->Security);
+        }
+    }
+
+    /**
      * Index method.
      *
      * @return void
@@ -57,4 +70,15 @@ class PostsController extends AppController
     {
         // Do nothing.
     }
+
+    /**
+     * Post endpoint for integration testing with security component.
+     *
+     * @return void
+     */
+    public function securePost()
+    {
+        $this->response->body('Request was accepted');
+        return $this->response;
+    }
 }

+ 1 - 1
tests/test_app/TestApp/Controller/RequestActionController.php

@@ -23,7 +23,7 @@ class RequestActionController extends AppController
 {
 
     /**
-     * modelClass property
+     * The default model to use.
      *
      * @var string
      */