Skip to content

Commit

Permalink
add CSRF validation by custom HTTP header
Browse files Browse the repository at this point in the history
  • Loading branch information
olegbaturin committed Nov 23, 2024
1 parent 5c16821 commit 717b285
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 4 deletions.
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
38 changes: 34 additions & 4 deletions framework/web/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

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

View check run for this annotation

Codecov / codecov/patch

framework/web/Request.php#L1800

Added line #L1800 was not covered by tests
}

if ($this->_csrfToken === null || $regenerate) {
$token = $this->loadCsrfToken();
if ($regenerate || empty($token)) {
Expand Down Expand Up @@ -1823,7 +1847,7 @@ protected function generateCsrfToken()
*/
public function getCsrfTokenFromHeader()
{
return $this->headers->get(static::CSRF_HEADER);
return $this->headers->get($this->csrfHeader);
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down
108 changes: 108 additions & 0 deletions tests/framework/web/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down

0 comments on commit 717b285

Please sign in to comment.