From 717b285115e69e0034132158e6b892d3cfe504d0 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 23 Nov 2024 18:58:06 +0700 Subject: [PATCH 1/5] add CSRF validation by custom HTTP header --- framework/CHANGELOG.md | 3 + framework/web/Request.php | 38 ++++++++-- tests/framework/web/RequestTest.php | 108 ++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 5e1986a4056..bca7572015c 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -13,6 +13,9 @@ Yii Framework 2 Change Log - Enh #20268: Minor optimisation in `\yii\helpers\BaseArrayHelper::map` (chriscpty) - Enh #20273: Remove unnecessary `paragonie/random_compat` dependency (timwolla) - Chg #20276: Removed autogenerated migration phpdoc (userator) +- New #20279: Add to the `\yii\web\Request` CSRF validation by custom HTTP header (olegbaturin) +- Enh #20279: Add to the `\yii\web\Request` `csrfHeader` property to configure a custom HTTP header for CSRF validation (olegbaturin) +- Enh #20279: Add to the `\yii\web\Request` `csrfTokenSafeMethods` property to configure a custom safe HTTP methods list (olegbaturin) 2.0.51 July 18, 2024 -------------------- diff --git a/framework/web/Request.php b/framework/web/Request.php index 270c9e796ff..0a66c3380f4 100644 --- a/framework/web/Request.php +++ b/framework/web/Request.php @@ -117,6 +117,26 @@ class Request extends \yii\base\Request * @see https://en.wikipedia.org/wiki/Cross-site_request_forgery */ public $enableCsrfValidation = true; + /** + * @var string the name of the HTTP header for sending CSRF token. + */ + public $csrfHeader = self::CSRF_HEADER; + /** + * @var array the name of the HTTP header for sending CSRF token. + * by default validate CSRF token on non-"safe" methods only + * @see https://tools.ietf.org/html/rfc2616#section-9.1.1 + */ + public $csrfTokenSafeMethods = ['GET', 'HEAD', 'OPTIONS']; + /** + * @var array "unsafe" methods not triggered a CORS-preflight request + * @see https://fetch.spec.whatwg.org/#http-cors-protocol + */ + public $csrfHeaderUnafeMethods = ['GET', 'HEAD', 'POST']; + /** + * @var bool whether to use custom header only to CSRF validation. Defaults to false. + * @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-custom-request-headers-for-ajaxapi + */ + public $validateCsrfHeaderOnly = false; /** * @var string the name of the token used to prevent CSRF. Defaults to '_csrf'. * This property is used only when [[enableCsrfValidation]] is true. @@ -1772,10 +1792,14 @@ protected function loadCookies() * along via a hidden field of an HTML form or an HTTP header value to support CSRF validation. * @param bool $regenerate whether to regenerate CSRF token. When this parameter is true, each time * this method is called, a new CSRF token will be generated and persisted (in session or cookie). - * @return string the token used to perform CSRF validation. + * @return null|string the token used to perform CSRF validation. */ public function getCsrfToken($regenerate = false) { + if ($this->validateCsrfHeaderOnly) { + return null; + } + if ($this->_csrfToken === null || $regenerate) { $token = $this->loadCsrfToken(); if ($regenerate || empty($token)) { @@ -1823,7 +1847,7 @@ protected function generateCsrfToken() */ public function getCsrfTokenFromHeader() { - return $this->headers->get(static::CSRF_HEADER); + return $this->headers->get($this->csrfHeader); } /** @@ -1860,8 +1884,14 @@ protected function createCsrfCookie($token) public function validateCsrfToken($clientSuppliedToken = null) { $method = $this->getMethod(); - // only validate CSRF token on non-"safe" methods https://tools.ietf.org/html/rfc2616#section-9.1.1 - if (!$this->enableCsrfValidation || in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) { + + if ($this->validateCsrfHeaderOnly) { + return in_array($method, $this->csrfHeaderUnafeMethods, true) + ? $this->headers->has($this->csrfHeader) + : true; + } + + if (!$this->enableCsrfValidation || in_array($method, $this->csrfTokenSafeMethods, true)) { return true; } diff --git a/tests/framework/web/RequestTest.php b/tests/framework/web/RequestTest.php index ec493e95b7c..c6673c2bf5f 100644 --- a/tests/framework/web/RequestTest.php +++ b/tests/framework/web/RequestTest.php @@ -211,6 +211,114 @@ public function testCsrfTokenHeader() } } + public function testCustomSafeMethodsCsrfTokenValidation() + { + $this->mockWebApplication(); + + $request = new Request(); + $request->csrfTokenSafeMethods = ['OPTIONS']; + $request->enableCsrfCookie = false; + $request->enableCsrfValidation = true; + + $token = $request->getCsrfToken(); + + // accept any value on custom safe request + foreach (['OPTIONS'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $this->assertTrue($request->validateCsrfToken($token)); + $this->assertTrue($request->validateCsrfToken($token . 'a')); + $this->assertTrue($request->validateCsrfToken([])); + $this->assertTrue($request->validateCsrfToken([$token])); + $this->assertTrue($request->validateCsrfToken(0)); + $this->assertTrue($request->validateCsrfToken(null)); + $this->assertTrue($request->validateCsrfToken()); + } + + // only accept valid token on other requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $this->assertTrue($request->validateCsrfToken($token)); + $this->assertFalse($request->validateCsrfToken($token . 'a')); + $this->assertFalse($request->validateCsrfToken([])); + $this->assertFalse($request->validateCsrfToken([$token])); + $this->assertFalse($request->validateCsrfToken(0)); + $this->assertFalse($request->validateCsrfToken(null)); + $this->assertFalse($request->validateCsrfToken()); + } + } + + public function testCsrfHeaderValidation() + { + $this->mockWebApplication(); + + $request = new Request(); + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid header on unsafe requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $request->headers->remove(Request::CSRF_HEADER); + $this->assertFalse($request->validateCsrfToken()); + + $request->headers->add(Request::CSRF_HEADER, ''); + $this->assertTrue($request->validateCsrfToken()); + } + + // accept no value on other requests + foreach (['DELETE', 'PATCH', 'PUT', 'OPTIONS'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $this->assertTrue($request->validateCsrfToken()); + } + } + + public function testCustomHeaderCsrfHeaderValidation() + { + $this->mockWebApplication(); + + $request = new Request(); + $request->csrfHeader = 'X-JGURDA'; + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid header on unsafe requests + foreach (['GET', 'HEAD', 'POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $request->headers->remove('X-JGURDA'); + $this->assertFalse($request->validateCsrfToken()); + + $request->headers->add('X-JGURDA', ''); + $this->assertTrue($request->validateCsrfToken()); + } + } + + public function testCustomUnsafeMethodsCsrfHeaderValidation() + { + $this->mockWebApplication(); + + $request = new Request(); + $request->csrfHeaderUnafeMethods = ['POST']; + $request->validateCsrfHeaderOnly = true; + $request->enableCsrfValidation = true; + + // only accept valid custom header on unsafe requests + foreach (['POST'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $request->headers->remove(Request::CSRF_HEADER); + $this->assertFalse($request->validateCsrfToken()); + + $request->headers->add(Request::CSRF_HEADER, ''); + $this->assertTrue($request->validateCsrfToken()); + } + + // accept no value on other requests + foreach (['GET', 'HEAD'] as $method) { + $_SERVER['REQUEST_METHOD'] = $method; + $request->headers->remove(Request::CSRF_HEADER); + $this->assertTrue($request->validateCsrfToken()); + } + } + public function testResolve() { $this->mockWebApplication([ From 2ef9471af629f44a658217cfa79a074395d171d1 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 23 Nov 2024 19:15:20 +0700 Subject: [PATCH 2/5] update tests --- tests/framework/web/RequestTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/framework/web/RequestTest.php b/tests/framework/web/RequestTest.php index c6673c2bf5f..1c29c4ed200 100644 --- a/tests/framework/web/RequestTest.php +++ b/tests/framework/web/RequestTest.php @@ -319,6 +319,16 @@ public function testCustomUnsafeMethodsCsrfHeaderValidation() } } + public function testNoCsrfTokenCsrfHeaderValidation() + { + $this->mockWebApplication(); + + $request = new Request(); + $request->validateCsrfHeaderOnly = true; + + $this->assertEquals($request->getCsrfToken(), null); + } + public function testResolve() { $this->mockWebApplication([ From 9a3797b11f0dda2002dbceb5dc32d6529884b593 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 24 Nov 2024 17:42:00 +0700 Subject: [PATCH 3/5] update phpdoc --- framework/web/Request.php | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/framework/web/Request.php b/framework/web/Request.php index 0a66c3380f4..7ecc9b86a0f 100644 --- a/framework/web/Request.php +++ b/framework/web/Request.php @@ -42,7 +42,7 @@ * not available. * @property-read CookieCollection $cookies The cookie collection. * @property-read string $csrfToken The token used to perform CSRF validation. - * @property-read string|null $csrfTokenFromHeader The CSRF token sent via [[CSRF_HEADER]] by browser. Null is + * @property-read string|null $csrfTokenFromHeader The CSRF token sent via [[csrfHeader]] by browser. Null is * returned if no such header is sent. * @property-read array $eTags The entity tags. * @property-read HeaderCollection $headers The header collection. @@ -91,7 +91,7 @@ class Request extends \yii\base\Request { /** - * The name of the HTTP header for sending CSRF token. + * Default name of the HTTP header for sending CSRF token. */ const CSRF_HEADER = 'X-CSRF-Token'; /** @@ -113,28 +113,38 @@ class Request extends \yii\base\Request * `yii.getCsrfToken()`, respectively. The [[\yii\web\YiiAsset]] asset must be registered. * You also need to include CSRF meta tags in your pages by using [[\yii\helpers\Html::csrfMetaTags()]]. * + * For SPA, you can use CSRF validation by custom header with a random or an empty value. + * Include a header with the name specified by [[csrfHeader]] to requests that must be validated. + * Warning! CSRF validation by custom header can be used only for same-origin requests or + * with CORS configured to allow requests from the list of specific origins only. + * * @see Controller::enableCsrfValidation * @see https://en.wikipedia.org/wiki/Cross-site_request_forgery */ public $enableCsrfValidation = true; - /** - * @var string the name of the HTTP header for sending CSRF token. + /** + * @var string the name of the HTTP header for sending CSRF token. Defaults [[CSRF_HEADER]]. + * This property can be changed for Yii API applications only. + * Don't change this property for Yii Web application. */ public $csrfHeader = self::CSRF_HEADER; /** * @var array the name of the HTTP header for sending CSRF token. * by default validate CSRF token on non-"safe" methods only + * This property is used only when [[enableCsrfValidation]] is true. * @see https://tools.ietf.org/html/rfc2616#section-9.1.1 */ public $csrfTokenSafeMethods = ['GET', 'HEAD', 'OPTIONS']; /** * @var array "unsafe" methods not triggered a CORS-preflight request + * This property is used only when both [[enableCsrfValidation]] and [[validateCsrfHeaderOnly]] are true. * @see https://fetch.spec.whatwg.org/#http-cors-protocol */ public $csrfHeaderUnafeMethods = ['GET', 'HEAD', 'POST']; /** - * @var bool whether to use custom header only to CSRF validation. Defaults to false. - * @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-custom-request-headers-for-ajaxapi + * @var bool whether to use custom header only to CSRF validation of SPA. Defaults to false. + * If false and [[enableCsrfValidation]] is true, CSRF validation by token will used. + * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-custom-request-headers-for-ajaxapi */ public $validateCsrfHeaderOnly = false; /** @@ -1792,7 +1802,7 @@ protected function loadCookies() * along via a hidden field of an HTML form or an HTTP header value to support CSRF validation. * @param bool $regenerate whether to regenerate CSRF token. When this parameter is true, each time * this method is called, a new CSRF token will be generated and persisted (in session or cookie). - * @return null|string the token used to perform CSRF validation. + * @return null|string the token used to perform CSRF validation. Null is returned if the [[validateCsrfHeaderOnly]] is true. */ public function getCsrfToken($regenerate = false) { @@ -1843,7 +1853,7 @@ protected function generateCsrfToken() } /** - * @return string|null the CSRF token sent via [[CSRF_HEADER]] by browser. Null is returned if no such header is sent. + * @return string|null the CSRF token sent via [[csrfHeader]] by browser. Null is returned if no such header is sent. */ public function getCsrfTokenFromHeader() { From e6e8311d575ed2f8e1389767b087e97362731f10 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 25 Nov 2024 13:58:16 +0700 Subject: [PATCH 4/5] fix phpdoc --- framework/web/Request.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/framework/web/Request.php b/framework/web/Request.php index 7ecc9b86a0f..0e6af8cbee9 100644 --- a/framework/web/Request.php +++ b/framework/web/Request.php @@ -123,8 +123,8 @@ class Request extends \yii\base\Request */ public $enableCsrfValidation = true; /** - * @var string the name of the HTTP header for sending CSRF token. Defaults [[CSRF_HEADER]]. - * This property can be changed for Yii API applications only. + * @var string the name of the HTTP header for sending CSRF token. Defaults to [[CSRF_HEADER]]. + * This property may be changed for Yii API applications only. * Don't change this property for Yii Web application. */ public $csrfHeader = self::CSRF_HEADER; @@ -132,7 +132,7 @@ class Request extends \yii\base\Request * @var array the name of the HTTP header for sending CSRF token. * by default validate CSRF token on non-"safe" methods only * This property is used only when [[enableCsrfValidation]] is true. - * @see https://tools.ietf.org/html/rfc2616#section-9.1.1 + * @see https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods */ public $csrfTokenSafeMethods = ['GET', 'HEAD', 'OPTIONS']; /** @@ -144,6 +144,7 @@ class Request extends \yii\base\Request /** * @var bool whether to use custom header only to CSRF validation of SPA. Defaults to false. * If false and [[enableCsrfValidation]] is true, CSRF validation by token will used. + * Warning! CSRF validation by custom header can be used for Yii API applications only. * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-custom-request-headers-for-ajaxapi */ public $validateCsrfHeaderOnly = false; From 36b34b0dc1e029c9686c771ba27f2d960b4afda4 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 28 Nov 2024 12:01:15 +0700 Subject: [PATCH 5/5] fix 'unafe' typo --- framework/web/Request.php | 4 ++-- tests/framework/web/RequestTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/web/Request.php b/framework/web/Request.php index 0e6af8cbee9..312654fc679 100644 --- a/framework/web/Request.php +++ b/framework/web/Request.php @@ -140,7 +140,7 @@ class Request extends \yii\base\Request * This property is used only when both [[enableCsrfValidation]] and [[validateCsrfHeaderOnly]] are true. * @see https://fetch.spec.whatwg.org/#http-cors-protocol */ - public $csrfHeaderUnafeMethods = ['GET', 'HEAD', 'POST']; + public $csrfHeaderUnsafeMethods = ['GET', 'HEAD', 'POST']; /** * @var bool whether to use custom header only to CSRF validation of SPA. Defaults to false. * If false and [[enableCsrfValidation]] is true, CSRF validation by token will used. @@ -1897,7 +1897,7 @@ public function validateCsrfToken($clientSuppliedToken = null) $method = $this->getMethod(); if ($this->validateCsrfHeaderOnly) { - return in_array($method, $this->csrfHeaderUnafeMethods, true) + return in_array($method, $this->csrfHeaderUnsafeMethods, true) ? $this->headers->has($this->csrfHeader) : true; } diff --git a/tests/framework/web/RequestTest.php b/tests/framework/web/RequestTest.php index 1c29c4ed200..932392e187d 100644 --- a/tests/framework/web/RequestTest.php +++ b/tests/framework/web/RequestTest.php @@ -297,7 +297,7 @@ public function testCustomUnsafeMethodsCsrfHeaderValidation() $this->mockWebApplication(); $request = new Request(); - $request->csrfHeaderUnafeMethods = ['POST']; + $request->csrfHeaderUnsafeMethods = ['POST']; $request->validateCsrfHeaderOnly = true; $request->enableCsrfValidation = true;