Skip to content
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
19 changes: 19 additions & 0 deletions app/Config/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ class Security extends BaseConfig
*/
public string $csrfProtection = 'cookie';

/**
* --------------------------------------------------------------------------
* CSRF Fetch Metadata
* --------------------------------------------------------------------------
*
* Whether to use Fetch Metadata request headers as a first-line CSRF check.
*/
public bool $csrfFetchMetadata = true;

/**
* --------------------------------------------------------------------------
* CSRF Fetch Metadata Reject Same Site
* --------------------------------------------------------------------------
*
* Whether requests with the Sec-Fetch-Site: same-site header should be
* rejected instead of falling back to token verification.
*/
public bool $csrfFetchMetadataRejectSameSite = false;

/**
* --------------------------------------------------------------------------
* CSRF Token Randomization
Expand Down
19 changes: 18 additions & 1 deletion system/Filters/CSRF.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace CodeIgniter\Filters;

use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
Expand Down Expand Up @@ -52,12 +53,19 @@ public function before(RequestInterface $request, $arguments = null)
$security->verify($request);
} catch (SecurityException $e) {
if ($security->shouldRedirect() && ! $request->isAJAX()) {
return redirect()->back()->with('error', $e->getMessage());
$response = redirect()->back()->with('error', $e->getMessage());
$this->addFetchMetadataVaryHeader($request, $response, $security);

return $response;
}

$this->addFetchMetadataVaryHeader($request, service('response'), $security);

throw $e;
}

$this->addFetchMetadataVaryHeader($request, service('response'), $security);
Comment thread
memleakd marked this conversation as resolved.

return null;
}

Expand All @@ -70,4 +78,13 @@ public function after(RequestInterface $request, ResponseInterface $response, $a
{
return null;
}

private function addFetchMetadataVaryHeader(IncomingRequest $request, ResponseInterface $response, Security $security): void
{
$isUnsafeMethod = in_array($request->getMethod(), [Method::POST, Method::PUT, Method::DELETE, Method::PATCH], true);

if ($security->shouldUseFetchMetadata() && $isUnsafeMethod) {
Comment thread
memleakd marked this conversation as resolved.
$response->appendHeader('Vary', 'Sec-Fetch-Site');
}
}
}
67 changes: 63 additions & 4 deletions system/Security/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@
*/
class Security implements SecurityInterface
{
public const CSRF_PROTECTION_COOKIE = 'cookie';
public const CSRF_PROTECTION_SESSION = 'session';
public const CSRF_PROTECTION_COOKIE = 'cookie';
public const CSRF_PROTECTION_SESSION = 'session';
private const FETCH_METADATA_ALLOW = 'allow';
private const FETCH_METADATA_FALLBACK = 'fallback';
private const FETCH_METADATA_REJECT = 'reject';

/**
* CSRF hash length in bytes.
Expand Down Expand Up @@ -118,6 +121,27 @@ public function verify(RequestInterface $request): static

assert($request instanceof IncomingRequest);

$decision = $this->fetchMetadataDecision($request);

if ($decision === self::FETCH_METADATA_ALLOW) {
$this->removeTokenInRequest($request);

log_message('info', 'CSRF Fetch Metadata verified.');

return $this;
}

if ($decision === self::FETCH_METADATA_REJECT) {
throw SecurityException::forDisallowedAction();
}

$this->verifyToken($request);

return $this;
}

private function verifyToken(IncomingRequest $request): void
{
$postedToken = $this->getPostedToken($request);

try {
Expand All @@ -139,8 +163,6 @@ public function verify(RequestInterface $request): static
}

log_message('info', 'CSRF token verified.');

return $this;
}

public function getHash(): ?string
Expand Down Expand Up @@ -170,6 +192,11 @@ public function shouldRedirect(): bool
return $this->config->redirect;
}

public function shouldUseFetchMetadata(): bool
{
return $this->config->csrfFetchMetadata ?? false; // @phpstan-ignore nullCoalesce.initializedProperty
}

/**
* @phpstan-assert string $this->hash
*/
Expand Down Expand Up @@ -229,6 +256,34 @@ private function isCsrfCookie(): bool
return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE;
}

/**
* @return self::FETCH_METADATA_*
*/
private function fetchMetadataDecision(IncomingRequest $request): string
{
if (! $this->shouldUseFetchMetadata()) {
return self::FETCH_METADATA_FALLBACK;
}

$fetchSite = strtolower($request->getHeaderLine('Sec-Fetch-Site'));

if ($fetchSite === 'same-origin') {
return self::FETCH_METADATA_ALLOW;
}

if ($fetchSite === 'cross-site') {
return self::FETCH_METADATA_REJECT;
}

if ($fetchSite === 'same-site') {
return ($this->config->csrfFetchMetadataRejectSameSite ?? false) // @phpstan-ignore nullCoalesce.initializedProperty
? self::FETCH_METADATA_REJECT
: self::FETCH_METADATA_FALLBACK;
}

return self::FETCH_METADATA_FALLBACK;
}

/**
* @phpstan-assert SessionInterface $this->session
*/
Expand Down Expand Up @@ -285,6 +340,10 @@ private function removeTokenInRequest(IncomingRequest $request): void
// If the token is found in form-encoded data, we can safely remove it.
parse_str($body, $result);

if (! array_key_exists($tokenName, $result)) {
return;
}

unset($result[$tokenName]);
$request->setBody(http_build_query($result));
}
Expand Down
109 changes: 109 additions & 0 deletions tests/system/Filters/CSRFTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@

namespace CodeIgniter\Filters;

use CodeIgniter\Config\Factories;
use CodeIgniter\Config\Services;
use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Response;
use CodeIgniter\Security\Exceptions\SecurityException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockSecurity;
use Config\Security as SecurityConfig;
use PHPUnit\Framework\Attributes\BackupGlobals;
use PHPUnit\Framework\Attributes\Group;

Expand All @@ -37,6 +43,14 @@ protected function setUp(): void
$this->config = new \Config\Filters();
}

protected function tearDown(): void
{
parent::tearDown();

$this->resetServices();
Factories::reset('config');
}

public function testDoNotCheckCliRequest(): void
{
$this->config->globals = [
Expand Down Expand Up @@ -73,4 +87,99 @@ public function testPassGetRequest(): void
// GET request is not protected, so no SecurityException will be thrown.
$this->assertSame($this->request, $request);
}

public function testBeforeAddsVaryHeaderForFetchMetadataVerification(): void
Comment thread
memleakd marked this conversation as resolved.
{
$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST')
->setHeader('Sec-Fetch-Site', 'same-origin');

$filter->before($request);

$this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary'));
}

public function testBeforeAddsVaryHeaderWhenFetchMetadataFallsBackToToken(): void
{
service('superglobals')
->setServer('REQUEST_METHOD', 'POST')
->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a')
->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a');

$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST');

$filter->before($request);

$this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary'));
}

public function testBeforeAppendsVaryHeaderForFetchMetadataVerification(): void
{
$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST')
->setHeader('Sec-Fetch-Site', 'same-origin');
service('response')->setHeader('Vary', 'Accept-Language');

$filter->before($request);

$this->assertSame('Accept-Language, Sec-Fetch-Site', service('response')->getHeaderLine('Vary'));
}

public function testBeforeAddsVaryHeaderToRedirectResponseForFetchMetadataVerification(): void
{
$config = new SecurityConfig();
$config->redirect = true;
Factories::injectMock('config', 'Security', $config);

$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST')
->setHeader('Sec-Fetch-Site', 'cross-site');

$response = $filter->before($request);

$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('Sec-Fetch-Site', $response->getHeaderLine('Vary'));
}

public function testBeforeThrowsExceptionForRejectedFetchMetadataVerification(): void
{
$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST')
->setHeader('Sec-Fetch-Site', 'cross-site');

try {
$filter->before($request);

$this->fail('Expected SecurityException was not thrown.');
} catch (SecurityException) {
$this->assertSame('Sec-Fetch-Site', service('response')->getHeaderLine('Vary'));
}
}

public function testBeforeUsesSecurityServiceConfigForVaryHeader(): void
{
service('superglobals')
->setServer('REQUEST_METHOD', 'POST')
->setPost('csrf_test_name', '8b9218a55906f9dcc1dc263dce7f005a')
->setCookie('csrf_cookie_name', '8b9218a55906f9dcc1dc263dce7f005a');

$config = new SecurityConfig();
$config->csrfFetchMetadata = false;
Services::injectMock('security', new MockSecurity($config));

$filter = new CSRF();
$request = single_service('incomingrequest', null)
->withMethod('POST')
->setHeader('Sec-Fetch-Site', 'same-origin');

$filter->before($request);

$this->assertSame('', service('response')->getHeaderLine('Vary'));
}
}
Loading
Loading