Browse Source

Started on turning the CSRF component into a middleware

Florian Krämer 9 years ago
parent
commit
fe1c6432c6

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

@@ -0,0 +1,177 @@
+<?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\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\I18n\Time;
+use Cake\Network\Exception\InvalidCsrfTokenException;
+use Cake\Utility\Security;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Provides CSRF protection & validation.
+ *
+ * This component 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 component 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
+     */
+    public function __construct(array $config = [])
+    {
+        $this->_config = $this->_defaultConfig + $config;
+    }
+
+    /**
+     * Serve assets if the path matches one.
+     *
+     * @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 = null;
+        if (isset($cookies[$this->_config['cookieName']])) {
+            $cookieData = isset($cookies[$this->_config['cookieName']]);
+        }
+
+        if (!empty($cookieData)) {
+            $params = $request->getAttribute('params');
+            $params['_csrfToken'] = $cookieData;
+            $request = $request->withAttribute('params', $params);
+        }
+
+        $method = $request->getMethod();
+        if ($method === 'requested') {
+            return $next($request, $response);
+        }
+
+        if ($method === 'GET' && $cookieData === null) {
+            $this->_setCookie($request, $response);
+
+            return $next($request, $response);
+        }
+
+        $this->_validateAndUnsetTokenField($request);
+
+        return $next($request, $response);
+    }
+
+    protected function _validateAndUnsetTokenField(ServerRequest &$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);
+            }
+        }
+    }
+
+    /**
+     * Set the cookie in the response.
+     *
+     * Also sets the request->params['_csrfToken'] so the newly minted
+     * token is available in the request data.
+     *
+     * @param \Cake\Http\ServerRequest $request The request object.
+     * @param \Cake\Http\Response $response The response object.
+     * @return void
+     */
+    protected function _setCookie(ServerRequest &$request, Response &$response)
+    {
+        $expiry = new Time($this->_config['expiry']);
+        $value = hash('sha512', Security::randomBytes(16), false);
+
+        $params = $request->getAttribute('params');
+        $params['_csrfToken'] = $value;
+        $request = $request->withAttribute('params', $params);
+
+        $response = $response->withCookie($this->_config['cookieName'], [
+            'value' => $value,
+            '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 \Cake\Http\ServerRequest $request The request to validate against.
+     * @throws \Cake\Network\Exception\InvalidCsrfTokenException when the CSRF token is invalid or missing.
+     * @return void
+     */
+    protected function _validateToken(ServerRequest $request)
+    {
+        $cookies = $request->getCookieParams();
+        $cookie = isset($cookies[$this->_config['cookieName']]) ? $cookies[$this->_config['cookieName']] : null;
+        $post = $request->getData($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.'));
+        }
+    }
+}

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

@@ -0,0 +1,74 @@
+<?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\ServerRequest;
+use Cake\TestSuite\TestCase;
+use Cake\Http\Response;
+
+/**
+ * Test for CsrfProtection
+ */
+class CsrfProtectionMiddlewareTest extends TestCase
+{
+
+    /**
+     * setup
+     *
+     * @return void
+     */
+    public function setUp()
+    {
+        parent::setUp();
+    }
+
+    /**
+     * teardown
+     *
+     * @return void
+     */
+    public function tearDown()
+    {
+        parent::tearDown();
+    }
+
+    /**
+     * Test setting the cookie value
+     *
+     * @return void
+     */
+    public function testSettingCookie()
+    {
+        $request = new ServerRequest([
+            'environment' => ['REQUEST_METHOD' => 'GET'],
+            'webroot' => '/dir/',
+        ]);
+        $response = new Response();
+
+        $callback = function($request, $response) {
+            return $response;
+        };
+        $middleware = new CsrfProtectionMiddleware();
+        $response = $middleware($request, $response, $callback);
+        $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']);
+    }
+}