Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add CSRF validation by custom HTTP header #20280

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------
Expand Down
54 changes: 47 additions & 7 deletions framework/web/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
/**
Expand All @@ -113,10 +113,40 @@
* `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. Defaults [[CSRF_HEADER]].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @var string the name of the HTTP header for sending CSRF token. Defaults [[CSRF_HEADER]].
* @var string the name of the HTTP header for sending CSRF token. Defaults to [[CSRF_HEADER]].

* This property can be changed for Yii API applications only.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* This property can be changed for Yii API applications only.
* This property may 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 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;
/**
* @var string the name of the token used to prevent CSRF. Defaults to '_csrf'.
* This property is used only when [[enableCsrfValidation]] is true.
Expand Down Expand Up @@ -1772,10 +1802,14 @@
* 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. Null is returned if the [[validateCsrfHeaderOnly]] is true.
*/
public function getCsrfToken($regenerate = false)
{
if ($this->validateCsrfHeaderOnly) {
return null;

Check warning on line 1810 in framework/web/Request.php

View check run for this annotation

Codecov / codecov/patch

framework/web/Request.php#L1809-L1810

Added lines #L1809 - L1810 were not covered by tests
}

if ($this->_csrfToken === null || $regenerate) {
$token = $this->loadCsrfToken();
if ($regenerate || empty($token)) {
Expand Down Expand Up @@ -1819,11 +1853,11 @@
}

/**
* @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()
{
return $this->headers->get(static::CSRF_HEADER);
return $this->headers->get($this->csrfHeader);

Check warning on line 1860 in framework/web/Request.php

View check run for this annotation

Codecov / codecov/patch

framework/web/Request.php#L1860

Added line #L1860 was not covered by tests
}

/**
Expand Down Expand Up @@ -1860,8 +1894,14 @@
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;

Check warning on line 1901 in framework/web/Request.php

View check run for this annotation

Codecov / codecov/patch

framework/web/Request.php#L1898-L1901

Added lines #L1898 - L1901 were not covered by tests
}

if (!$this->enableCsrfValidation || in_array($method, $this->csrfTokenSafeMethods, true)) {

Check warning on line 1904 in framework/web/Request.php

View check run for this annotation

Codecov / codecov/patch

framework/web/Request.php#L1904

Added line #L1904 was not covered by tests
return true;
}

Expand Down
118 changes: 118 additions & 0 deletions tests/framework/web/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,124 @@ 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 testNoCsrfTokenCsrfHeaderValidation()
{
$this->mockWebApplication();

$request = new Request();
$request->validateCsrfHeaderOnly = true;

$this->assertEquals($request->getCsrfToken(), null);
}

public function testResolve()
{
$this->mockWebApplication([
Expand Down
Loading