CsrfProtectionMiddleware.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.5.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Http\Middleware;
  16. use Cake\Http\Cookie\Cookie;
  17. use Cake\Http\Exception\InvalidCsrfTokenException;
  18. use Cake\Http\Response;
  19. use Cake\Http\ServerRequest;
  20. use Cake\I18n\Time;
  21. use Cake\Utility\Hash;
  22. use Cake\Utility\Security;
  23. /**
  24. * Provides CSRF protection & validation.
  25. *
  26. * This middleware adds a CSRF token to a cookie. The cookie value is compared to
  27. * request data, or the X-CSRF-Token header on each PATCH, POST,
  28. * PUT, or DELETE request.
  29. *
  30. * If the request data is missing or does not match the cookie data,
  31. * an InvalidCsrfTokenException will be raised.
  32. *
  33. * This middleware integrates with the FormHelper automatically and when
  34. * used together your forms will have CSRF tokens automatically added
  35. * when `$this->Form->create(...)` is used in a view.
  36. */
  37. class CsrfProtectionMiddleware
  38. {
  39. /**
  40. * Default config for the CSRF handling.
  41. *
  42. * - `cookieName` The name of the cookie to send.
  43. * - `expiry` A strotime compatible value of how long the CSRF token should last.
  44. * Defaults to browser session.
  45. * - `secure` Whether or not the cookie will be set with the Secure flag. Defaults to false.
  46. * - `httpOnly` Whether or not the cookie will be set with the HttpOnly flag. Defaults to false.
  47. * - `samesite` Value for "SameSite" attribute. Default to null.
  48. * - `field` The form field to check. Changing this will also require configuring
  49. * FormHelper.
  50. * - `verifyTokenSource` Generate and verify tokens that include the application salt
  51. * value. This prevents tokens from being manipulated by an attacker via XSS or physical
  52. * access. This behavior is disabled by default as it is not cross compatible with tokens
  53. * created in earlier versions of CakePHP. It is recommended that you enable this setting
  54. * if possible as it is the default in 4.x.
  55. *
  56. * @var array
  57. */
  58. protected $_defaultConfig = [
  59. 'cookieName' => 'csrfToken',
  60. 'expiry' => 0,
  61. 'secure' => false,
  62. 'httpOnly' => false,
  63. 'samesite' => null,
  64. 'field' => '_csrfToken',
  65. 'verifyTokenSource' => false,
  66. ];
  67. /**
  68. * Configuration
  69. *
  70. * @var array
  71. */
  72. protected $_config = [];
  73. /**
  74. * Callback for deciding whether or not to skip the token check for particular request.
  75. *
  76. * CSRF protection token check will be skipped if the callback returns `true`.
  77. *
  78. * @var callable|null
  79. */
  80. protected $whitelistCallback;
  81. /**
  82. * @var int
  83. */
  84. const TOKEN_VALUE_LENGTH = 16;
  85. /**
  86. * Constructor
  87. *
  88. * @param array $config Config options. See $_defaultConfig for valid keys.
  89. */
  90. public function __construct(array $config = [])
  91. {
  92. $this->_config = $config + $this->_defaultConfig;
  93. }
  94. /**
  95. * Checks and sets the CSRF token depending on the HTTP verb.
  96. *
  97. * @param \Cake\Http\ServerRequest $request The request.
  98. * @param \Cake\Http\Response $response The response.
  99. * @param callable $next Callback to invoke the next middleware.
  100. * @return \Cake\Http\Response A response
  101. */
  102. public function __invoke(ServerRequest $request, Response $response, $next)
  103. {
  104. if (
  105. $this->whitelistCallback !== null
  106. && call_user_func($this->whitelistCallback, $request) === true
  107. ) {
  108. return $next($request, $response);
  109. }
  110. $cookies = $request->getCookieParams();
  111. $cookieData = Hash::get($cookies, $this->_config['cookieName']);
  112. if (is_string($cookieData) && strlen($cookieData) > 0) {
  113. $params = $request->getAttribute('params');
  114. $params['_csrfToken'] = $cookieData;
  115. $request = $request->withAttribute('params', $params);
  116. }
  117. $method = $request->getMethod();
  118. if ($method === 'GET' && $cookieData === null) {
  119. $token = $this->createToken();
  120. $request = $this->_addTokenToRequest($token, $request);
  121. $response = $this->_addTokenCookie($token, $request, $response);
  122. return $next($request, $response);
  123. }
  124. $request = $this->_validateAndUnsetTokenField($request);
  125. return $next($request, $response);
  126. }
  127. /**
  128. * Set callback for allowing to skip token check for particular request.
  129. *
  130. * The callback will receive request instance as argument and must return
  131. * `true` if you want to skip token check for the current request.
  132. *
  133. * @param callable $callback A callable.
  134. * @return $this
  135. */
  136. public function whitelistCallback(callable $callback)
  137. {
  138. $this->whitelistCallback = $callback;
  139. return $this;
  140. }
  141. /**
  142. * Checks if the request is POST, PUT, DELETE or PATCH and validates the CSRF token
  143. *
  144. * @param \Cake\Http\ServerRequest $request The request object.
  145. * @return \Cake\Http\ServerRequest
  146. */
  147. protected function _validateAndUnsetTokenField(ServerRequest $request)
  148. {
  149. if (in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH'], true) || $request->getData()) {
  150. $this->_validateToken($request);
  151. $body = $request->getParsedBody();
  152. if (is_array($body)) {
  153. unset($body[$this->_config['field']]);
  154. $request = $request->withParsedBody($body);
  155. }
  156. }
  157. return $request;
  158. }
  159. /**
  160. * Create a new token to be used for CSRF protection
  161. *
  162. * @return string
  163. */
  164. protected function _createToken()
  165. {
  166. return $this->createToken();
  167. }
  168. /**
  169. * Create a new token to be used for CSRF protection.
  170. *
  171. * @return string
  172. */
  173. public function createToken()
  174. {
  175. $value = Security::randomBytes(static::TOKEN_VALUE_LENGTH);
  176. if (!$this->_config['verifyTokenSource']) {
  177. return hash('sha512', $value, false);
  178. }
  179. return $value . hash_hmac('sha1', $value, Security::getSalt());
  180. }
  181. /**
  182. * Add a CSRF token to the request parameters.
  183. *
  184. * @param string $token The token to add.
  185. * @param \Cake\Http\ServerRequest $request The request to augment
  186. * @return \Cake\Http\ServerRequest Modified request
  187. */
  188. protected function _addTokenToRequest($token, ServerRequest $request)
  189. {
  190. $params = $request->getAttribute('params');
  191. $params['_csrfToken'] = $token;
  192. return $request->withAttribute('params', $params);
  193. }
  194. /**
  195. * Add a CSRF token to the response cookies.
  196. *
  197. * @param string $token The token to add.
  198. * @param \Cake\Http\ServerRequest $request The request to validate against.
  199. * @param \Cake\Http\Response $response The response.
  200. * @return \Cake\Http\Response $response Modified response.
  201. */
  202. protected function _addTokenCookie($token, ServerRequest $request, Response $response)
  203. {
  204. $expiry = new Time($this->_config['expiry']);
  205. $cookie = new Cookie(
  206. $this->_config['cookieName'],
  207. $token,
  208. $expiry,
  209. $request->getAttribute('webroot'),
  210. '',
  211. (bool)$this->_config['secure'],
  212. (bool)$this->_config['httpOnly'],
  213. isset($this->_config['samesite']) ? $this->_config['samesite'] : $this->_defaultConfig['samesite']
  214. );
  215. return $response->withCookie($cookie);
  216. }
  217. /**
  218. * Validate the request data against the cookie token.
  219. *
  220. * @param \Cake\Http\ServerRequest $request The request to validate against.
  221. * @return void
  222. * @throws \Cake\Http\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing.
  223. */
  224. protected function _validateToken(ServerRequest $request)
  225. {
  226. $cookies = $request->getCookieParams();
  227. $cookie = Hash::get($cookies, $this->_config['cookieName']);
  228. $post = Hash::get($request->getParsedBody(), $this->_config['field']);
  229. $header = $request->getHeaderLine('X-CSRF-Token');
  230. if (!$cookie) {
  231. throw new InvalidCsrfTokenException(__d('cake', 'Missing CSRF token cookie'));
  232. }
  233. if ($this->_config['verifyTokenSource']) {
  234. // This path validates that the token was generated by our application.
  235. if ($this->_compareToken($post, $cookie) || $this->_compareToken($header, $cookie)) {
  236. return;
  237. }
  238. throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.'));
  239. }
  240. // Backwards compatibility mode. This path compares tokens as opaque strings.
  241. if (Security::constantEquals($post, $cookie) || Security::constantEquals($header, $cookie)) {
  242. return;
  243. }
  244. throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.'));
  245. }
  246. /**
  247. * Ensure that the request token matches the cookie value and that
  248. * both were generated by us.
  249. *
  250. * @param mixed $post The request token.
  251. * @param mixed $cookie The cookie token.
  252. * @return bool
  253. */
  254. protected function _compareToken($post, $cookie)
  255. {
  256. if (!is_string($post)) {
  257. $post = '';
  258. }
  259. if (!is_string($cookie)) {
  260. $cookie = '';
  261. }
  262. $postKey = (string)substr($post, 0, static::TOKEN_VALUE_LENGTH);
  263. $postHmac = (string)substr($post, static::TOKEN_VALUE_LENGTH);
  264. $cookieKey = (string)substr($cookie, 0, static::TOKEN_VALUE_LENGTH);
  265. $cookieHmac = (string)substr($cookie, static::TOKEN_VALUE_LENGTH);
  266. // Put all checks in a list
  267. // so they all burn time reducing timing attack window.
  268. $checks = [
  269. hash_equals($postKey, $cookieKey),
  270. hash_equals($postHmac, $cookieHmac),
  271. hash_equals(
  272. $postHmac,
  273. hash_hmac('sha1', $postKey, Security::getSalt())
  274. ),
  275. ];
  276. foreach ($checks as $check) {
  277. if ($check !== true) {
  278. return false;
  279. }
  280. }
  281. return true;
  282. }
  283. }