Browse Source

Merge pull request #10561 from cakephp/3.next-csrf-middleware

Turning the CSRF component into a middleware
Mark Story 8 years ago
parent
commit
f947384133

+ 197 - 0
src/Http/Middleware/CsrfProtectionMiddleware.php

@@ -0,0 +1,197 @@
+<?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.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Middleware;
+
+use Cake\I18n\Time;
+use Cake\Network\Exception\InvalidCsrfTokenException;
+use Cake\Utility\Hash;
+use Cake\Utility\Security;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Provides CSRF protection & validation.
+ *
+ * This middleware adds a CSRF token to a cookie. The cookie value is compared to
+ * request data, or the X-CSRF-Token header on each PATCH, POST,
+ * PUT, or DELETE request.
+ *
+ * If the request data is missing or does not match the cookie data,
+ * an InvalidCsrfTokenException will be raised.
+ *
+ * This middleware integrates with the FormHelper automatically and when
+ * used together your forms will have CSRF tokens automatically added
+ * when `$this->Form->create(...)` is used in a view.
+ */
+class CsrfProtectionMiddleware
+{
+    /**
+     * Default config for the CSRF handling.
+     *
+     *  - `cookieName` = The name of the cookie to send.
+     *  - `expiry` = How long the CSRF token should last. Defaults to browser session.
+     *  - `secure` = Whether or not the cookie will be set with the Secure flag. Defaults to false.
+     *  - `httpOnly` = Whether or not the cookie will be set with the HttpOnly flag. Defaults to false.
+     *  - `field` = The form field to check. Changing this will also require configuring
+     *    FormHelper.
+     *
+     * @var array
+     */
+    protected $_defaultConfig = [
+        'cookieName' => 'csrfToken',
+        'expiry' => 0,
+        'secure' => false,
+        'httpOnly' => false,
+        'field' => '_csrfToken',
+    ];
+
+    /**
+     * Configuration
+     *
+     * @var array
+     */
+    protected $_config = [];
+
+    /**
+     * Constructor
+     *
+     * @param array $config Config options. See $_defaultConfig for valid keys.
+     */
+    public function __construct(array $config = [])
+    {
+        $this->_config = $config + $this->_defaultConfig;
+    }
+
+    /**
+     * Checks and sets the CSRF token depending on the HTTP verb.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
+     * @param \Psr\Http\Message\ResponseInterface $response The response.
+     * @param callable $next Callback to invoke the next middleware.
+     * @return \Psr\Http\Message\ResponseInterface A response
+     */
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next)
+    {
+        $cookies = $request->getCookieParams();
+        $cookieData = Hash::get($cookies, $this->_config['cookieName']);
+
+        if (strlen($cookieData) > 0) {
+            $params = $request->getAttribute('params');
+            $params['_csrfToken'] = $cookieData;
+            $request = $request->withAttribute('params', $params);
+        }
+
+        $method = $request->getMethod();
+        if ($method === 'GET' && $cookieData === null) {
+            $token = $this->_createToken();
+            $request = $this->_addTokenToRequest($token, $request);
+            $response = $this->_addTokenCookie($token, $request, $response);
+
+            return $next($request, $response);
+        }
+        $request = $this->_validateAndUnsetTokenField($request);
+
+        return $next($request, $response);
+    }
+
+    /**
+     * Checks if the request is POST, PUT, DELETE or PATCH and validates the CSRF token
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request object.
+     * @return \Psr\Http\Message\ServerRequestInterface
+     */
+    protected function _validateAndUnsetTokenField(ServerRequestInterface $request)
+    {
+        if (in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH']) || $request->getData()) {
+            $this->_validateToken($request);
+            $body = $request->getParsedBody();
+            if (is_array($body)) {
+                unset($body[$this->_config['field']]);
+                $request = $request->withParsedBody($body);
+            }
+        }
+
+        return $request;
+    }
+
+    /**
+     * Create a new token to be used for CSRF protection
+     *
+     * @return string
+     */
+    protected function _createToken()
+    {
+        return hash('sha512', Security::randomBytes(16), false);
+    }
+
+    /**
+     * Add a CSRF token to the request parameters.
+     *
+     * @param string $token The token to add.
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to augment
+     * @return \Psr\Http\Message\ServerRequestInterface Modified request
+     */
+    protected function _addTokenToRequest($token, ServerRequestInterface $request)
+    {
+        $params = $request->getAttribute('params');
+        $params['_csrfToken'] = $token;
+
+        return $request->withAttribute('params', $params);
+    }
+
+    /**
+     * Add a CSRF token to the response cookies.
+     *
+     * @param string $token The token to add.
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to validate against.
+     * @param \Psr\Http\Message\ResponseInterface $response The response.
+     * @return @param \Psr\Http\Message\ResponseInterface $response Modified response.
+     */
+    protected function _addTokenCookie($token, ServerRequestInterface $request, ResponseInterface $response)
+    {
+        $expiry = new Time($this->_config['expiry']);
+
+        return $response->withCookie($this->_config['cookieName'], [
+            'value' => $token,
+            'expire' => $expiry->format('U'),
+            'path' => $request->getAttribute('webroot'),
+            'secure' => $this->_config['secure'],
+            'httpOnly' => $this->_config['httpOnly'],
+        ]);
+    }
+
+    /**
+     * Validate the request data against the cookie token.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to validate against.
+     * @return void
+     * @throws \Cake\Network\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing.
+     */
+    protected function _validateToken(ServerRequestInterface $request)
+    {
+        $cookies = $request->getCookieParams();
+        $cookie = Hash::get($cookies, $this->_config['cookieName']);
+        $post = Hash::get($request->getParsedBody(), $this->_config['field']);
+        $header = $request->getHeaderLine('X-CSRF-Token');
+
+        if (!$cookie) {
+            throw new InvalidCsrfTokenException(__d('cake', 'Missing CSRF token cookie'));
+        }
+
+        if ($post !== $cookie && $header !== $cookie) {
+            throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.'));
+        }
+    }
+}

+ 2 - 2
tests/TestCase/Controller/Component/CsrfComponentTest.php

@@ -89,7 +89,7 @@ class CsrfComponentTest extends TestCase
      *
      * HEAD and GET do not populate $_POST or request->data.
      *
-     * @return void
+     * @return array
      */
     public static function safeHttpMethodProvider()
     {
@@ -127,7 +127,7 @@ class CsrfComponentTest extends TestCase
     /**
      * Data provider for HTTP methods that can contain request bodies.
      *
-     * @return void
+     * @return array
      */
     public static function httpMethodProvider()
     {

+ 312 - 0
tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php

@@ -0,0 +1,312 @@
+<?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.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http\Middleware;
+
+use Cake\Http\Middleware\CsrfProtectionMiddleware;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\I18n\Time;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Test for CsrfProtection
+ */
+class CsrfProtectionMiddlewareTest extends TestCase
+{
+
+    /**
+     * Data provider for HTTP method tests.
+     *
+     * HEAD and GET do not populate $_POST or request->data.
+     *
+     * @return array
+     */
+    public static function safeHttpMethodProvider()
+    {
+        return [
+            ['GET'],
+            ['HEAD'],
+        ];
+    }
+
+    /**
+     * Data provider for HTTP methods that can contain request bodies.
+     *
+     * @return array
+     */
+    public static function httpMethodProvider()
+    {
+        return [
+            ['OPTIONS'], ['PATCH'], ['PUT'], ['POST'], ['DELETE'], ['PURGE'], ['INVALIDMETHOD']
+        ];
+    }
+
+    /**
+     * Provides the callback for the next middleware
+     *
+     * @return callable
+     */
+    protected function _getNextClosure()
+    {
+        return function ($request, $response) {
+            return $response;
+        };
+    }
+
+    /**
+     * Test setting the cookie value
+     *
+     * @return void
+     */
+    public function testSettingCookie()
+    {
+        $request = new ServerRequest([
+            'environment' => ['REQUEST_METHOD' => 'GET'],
+            'webroot' => '/dir/',
+        ]);
+        $response = new Response();
+
+        $closure = function ($request, $response) {
+            $cookie = $response->cookie('csrfToken');
+            $this->assertNotEmpty($cookie, 'Should set a token.');
+            $this->assertRegExp('/^[a-f0-9]+$/', $cookie['value'], 'Should look like a hash.');
+            $this->assertEquals(0, $cookie['expire'], 'session duration.');
+            $this->assertEquals('/dir/', $cookie['path'], 'session path.');
+            $this->assertEquals($cookie['value'], $request->params['_csrfToken']);
+        };
+
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $closure);
+    }
+
+    /**
+     * Test that the CSRF tokens are not required for idempotent operations
+     *
+     * @dataProvider safeHttpMethodProvider
+     * @return void
+     */
+    public function testSafeMethodNoCsrfRequired($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'HTTP_X_CSRF_TOKEN' => 'nope',
+            ],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        // No exception means the test is valid
+        $middleware = new CsrfProtectionMiddleware();
+        $response = $middleware($request, $response, $this->_getNextClosure());
+        $this->assertInstanceOf(Response::class, $response);
+    }
+
+    /**
+     * Test that the X-CSRF-Token works with the various http methods.
+     *
+     * @dataProvider httpMethodProvider
+     * @return void
+     */
+    public function testValidTokenInHeader($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'HTTP_X_CSRF_TOKEN' => 'testing123',
+            ],
+            'post' => ['a' => 'b'],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        // No exception means the test is valid
+        $middleware = new CsrfProtectionMiddleware();
+        $response = $middleware($request, $response, $this->_getNextClosure());
+        $this->assertInstanceOf(Response::class, $response);
+    }
+
+    /**
+     * Test that the X-CSRF-Token works with the various http methods.
+     *
+     * @dataProvider httpMethodProvider
+     * @expectedException \Cake\Network\Exception\InvalidCsrfTokenException
+     * @return void
+     */
+    public function testInvalidTokenInHeader($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'HTTP_X_CSRF_TOKEN' => 'nope',
+            ],
+            'post' => ['a' => 'b'],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $this->_getNextClosure());
+    }
+
+    /**
+     * Test that request data works with the various http methods.
+     *
+     * @dataProvider httpMethodProvider
+     * @return void
+     */
+    public function testValidTokenRequestData($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+            ],
+            'post' => ['_csrfToken' => 'testing123'],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        $closure = function ($request, $response) {
+            $this->assertNull($request->getData('_csrfToken'));
+        };
+
+        // No exception means everything is OK
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $closure);
+    }
+
+    /**
+     * Test that request data works with the various http methods.
+     *
+     * @dataProvider httpMethodProvider
+     * @expectedException \Cake\Network\Exception\InvalidCsrfTokenException
+     * @return void
+     */
+    public function testInvalidTokenRequestData($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+            ],
+            'post' => ['_csrfToken' => 'nope'],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $this->_getNextClosure());
+    }
+
+    /**
+     * Test that missing post field fails
+     *
+     * @expectedException \Cake\Network\Exception\InvalidCsrfTokenException
+     * @return void
+     */
+    public function testInvalidTokenRequestDataMissing()
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+            ],
+            'post' => [],
+            'cookies' => ['csrfToken' => 'testing123']
+        ]);
+        $response = new Response();
+
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $this->_getNextClosure());
+    }
+
+    /**
+     * Test that missing header and cookie fails
+     *
+     * @dataProvider httpMethodProvider
+     * @expectedException \Cake\Network\Exception\InvalidCsrfTokenException
+     * @return void
+     */
+    public function testInvalidTokenMissingCookie($method)
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method
+            ],
+            'post' => ['_csrfToken' => 'could-be-valid'],
+            'cookies' => []
+        ]);
+        $response = new Response();
+
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware($request, $response, $this->_getNextClosure());
+    }
+
+    /**
+     * Test that the configuration options work.
+     *
+     * @return void
+     */
+    public function testConfigurationCookieCreate()
+    {
+        $request = new ServerRequest([
+            'environment' => ['REQUEST_METHOD' => 'GET'],
+            'webroot' => '/dir/'
+        ]);
+        $response = new Response();
+
+        $closure = function ($request, $response) {
+            $this->assertEmpty($response->cookie('csrfToken'));
+            $cookie = $response->cookie('token');
+            $this->assertNotEmpty($cookie, 'Should set a token.');
+            $this->assertRegExp('/^[a-f0-9]+$/', $cookie['value'], 'Should look like a hash.');
+            $this->assertWithinRange((new Time('+1 hour'))->format('U'), $cookie['expire'], 1, 'session duration.');
+            $this->assertEquals('/dir/', $cookie['path'], 'session path.');
+            $this->assertTrue($cookie['secure'], 'cookie security flag missing');
+            $this->assertTrue($cookie['httpOnly'], 'cookie httpOnly flag missing');
+        };
+
+        $middleware = new CsrfProtectionMiddleware([
+            'cookieName' => 'token',
+            'expiry' => '+1 hour',
+            'secure' => true,
+            'httpOnly' => true
+        ]);
+        $middleware($request, $response, $closure);
+    }
+
+    /**
+     * Test that the configuration options work.
+     *
+     * There should be no exception thrown.
+     *
+     * @return void
+     */
+    public function testConfigurationValidate()
+    {
+        $request = new ServerRequest([
+            'environment' => ['REQUEST_METHOD' => 'POST'],
+            'cookies' => ['csrfToken' => 'nope', 'token' => 'yes'],
+            'post' => ['_csrfToken' => 'no match', 'token' => 'yes'],
+        ]);
+        $response = new Response();
+
+        $middleware = new CsrfProtectionMiddleware([
+            'cookieName' => 'token',
+            'field' => 'token',
+            'expiry' => 90,
+        ]);
+        $response = $middleware($request, $response, $this->_getNextClosure());
+        $this->assertInstanceOf(Response::class, $response);
+    }
+}