diff --git a/app/Config/Security.php b/app/Config/Security.php index 01000ff52088..563cf2f3a86e 100644 --- a/app/Config/Security.php +++ b/app/Config/Security.php @@ -6,6 +6,17 @@ class Security extends BaseConfig { + /** + * -------------------------------------------------------------------------- + * CSRF Protection Method + * -------------------------------------------------------------------------- + * + * Protection Method for Cross Site Request Forgery protection. + * + * @var string 'cookie' or 'session' + */ + public $csrfProtection = 'cookie'; + /** * -------------------------------------------------------------------------- * CSRF Token Name diff --git a/depfile.yaml b/depfile.yaml index c1d8b87c3db2..2c87969f4fc4 100644 --- a/depfile.yaml +++ b/depfile.yaml @@ -193,6 +193,7 @@ ruleset: - HTTP Security: - Cookie + - Session - HTTP Session: - Cookie diff --git a/env b/env index 38eabf203988..0b6aa1720782 100644 --- a/env +++ b/env @@ -110,6 +110,7 @@ # SECURITY #-------------------------------------------------------------------- +# security.csrfProtection = 'cookie' # security.tokenName = 'csrf_token_name' # security.headerName = 'X-CSRF-TOKEN' # security.cookieName = 'csrf_cookie_name' diff --git a/system/Security/Security.php b/system/Security/Security.php index 7bf3b6689095..d6edbb23025a 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -14,6 +14,7 @@ use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Session\Session; use Config\App; use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; @@ -27,6 +28,18 @@ */ class Security implements SecurityInterface { + public const CSRF_PROTECTION_COOKIE = 'cookie'; + public const CSRF_PROTECTION_SESSION = 'session'; + + /** + * CSRF Protection Method + * + * Protection Method for Cross Site Request Forgery protection. + * + * @var string 'cookie' or 'session' + */ + protected $csrfProtection = self::CSRF_PROTECTION_COOKIE; + /** * CSRF Hash * @@ -128,6 +141,13 @@ class Security implements SecurityInterface */ private $rawCookieName; + /** + * Session instance. + * + * @var Session + */ + private $session; + /** * Constructor. * @@ -141,11 +161,12 @@ public function __construct(App $config) // Store CSRF-related configurations if ($security instanceof SecurityConfig) { - $this->tokenName = $security->tokenName ?? $this->tokenName; - $this->headerName = $security->headerName ?? $this->headerName; - $this->regenerate = $security->regenerate ?? $this->regenerate; - $this->rawCookieName = $security->cookieName ?? $this->rawCookieName; - $this->expires = $security->expires ?? $this->expires; + $this->csrfProtection = $security->csrfProtection ?? $this->csrfProtection; + $this->tokenName = $security->tokenName ?? $this->tokenName; + $this->headerName = $security->headerName ?? $this->headerName; + $this->regenerate = $security->regenerate ?? $this->regenerate; + $this->rawCookieName = $security->cookieName ?? $this->rawCookieName; + $this->expires = $security->expires ?? $this->expires; } else { // `Config/Security.php` is absence $this->tokenName = $config->CSRFTokenName ?? $this->tokenName; @@ -155,13 +176,28 @@ public function __construct(App $config) $this->expires = $config->CSRFExpire ?? $this->expires; } - $this->configureCookie($config); + if ($this->isCSRFCookie()) { + $this->configureCookie($config); + } else { + // Session based CSRF protection + $this->configureSession(); + } $this->request = Services::request(); $this->generateHash(); } + private function isCSRFCookie(): bool + { + return $this->csrfProtection === self::CSRF_PROTECTION_COOKIE; + } + + private function configureSession(): void + { + $this->session = Services::session(); + } + private function configureCookie(App $config): void { /** @var CookieConfig|null $cookie */ @@ -253,7 +289,12 @@ public function verify(RequestInterface $request) if ($this->regenerate) { $this->hash = null; - unset($_COOKIE[$this->cookieName]); + if ($this->isCSRFCookie()) { + unset($_COOKIE[$this->cookieName]); + } else { + // Session based CSRF protection + $this->session->remove($this->tokenName); + } } $this->generateHash(); @@ -409,13 +450,23 @@ protected function generateHash(): string // We don't necessarily want to regenerate it with // each page load since a page could contain embedded // sub-pages causing this feature to fail - if ($this->isHashInCookie()) { - return $this->hash = $_COOKIE[$this->cookieName]; + if ($this->isCSRFCookie()) { + if ($this->isHashInCookie()) { + return $this->hash = $_COOKIE[$this->cookieName]; + } + } elseif ($this->session->has($this->tokenName)) { + // Session based CSRF protection + return $this->hash = $this->session->get($this->tokenName); } $this->hash = bin2hex(random_bytes(16)); - $this->saveHashInCookie(); + if ($this->isCSRFCookie()) { + $this->saveHashInCookie(); + } else { + // Session based CSRF protection + $this->saveHashInSession(); + } } return $this->hash; @@ -428,7 +479,7 @@ private function isHashInCookie(): bool && preg_match('#^[0-9a-f]{32}$#iS', $_COOKIE[$this->cookieName]) === 1; } - private function saveHashInCookie() + private function saveHashInCookie(): void { $this->cookie = new Cookie( $this->rawCookieName, @@ -467,4 +518,9 @@ protected function doSendCookie(): void { cookies([$this->cookie], false)->dispatch(); } + + private function saveHashInSession(): void + { + $this->session->set($this->tokenName, $this->hash); + } } diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php new file mode 100644 index 000000000000..040f253b9774 --- /dev/null +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Security; + +use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Security\Exceptions\SecurityException; +use CodeIgniter\Session\Handlers\ArrayHandler; +use CodeIgniter\Session\Session; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use CodeIgniter\Test\Mock\MockSession; +use CodeIgniter\Test\TestLogger; +use Config\App as AppConfig; +use Config\Logger as LoggerConfig; +use Config\Security as SecurityConfig; + +/** + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + * + * @internal + */ +final class SecurityCSRFSessionTest extends CIUnitTestCase +{ + /** + * @var string CSRF protection hash + */ + private $hash = '8b9218a55906f9dcc1dc263dce7f005a'; + + protected function setUp(): void + { + parent::setUp(); + + $_SESSION = []; + Factories::reset(); + + $config = new SecurityConfig(); + $config->csrfProtection = Security::CSRF_PROTECTION_SESSION; + Factories::injectMock('config', 'Security', $config); + + $this->injectSession($this->hash); + } + + private function createSession($options = []): Session + { + $defaults = [ + 'sessionDriver' => 'CodeIgniter\Session\Handlers\FileHandler', + 'sessionCookieName' => 'ci_session', + 'sessionExpiration' => 7200, + 'sessionSavePath' => null, + 'sessionMatchIP' => false, + 'sessionTimeToUpdate' => 300, + 'sessionRegenerateDestroy' => false, + 'cookieDomain' => '', + 'cookiePrefix' => '', + 'cookiePath' => '/', + 'cookieSecure' => false, + 'cookieSameSite' => 'Lax', + ]; + + $config = array_merge($defaults, $options); + $appConfig = new AppConfig(); + + foreach ($config as $key => $c) { + $appConfig->{$key} = $c; + } + + $session = new MockSession(new ArrayHandler($appConfig, '127.0.0.1'), $appConfig); + $session->setLogger(new TestLogger(new LoggerConfig())); + + return $session; + } + + private function injectSession(string $hash): void + { + $session = $this->createSession(); + $session->set('csrf_test_name', $hash); + Services::injectMock('session', $session); + } + + public function testHashIsReadFromSession() + { + $security = new Security(new MockAppConfig()); + + $this->assertSame($this->hash, $security->getHash()); + } + + public function testCSRFVerifyPostThrowsExceptionOnNoMatch() + { + $this->expectException(SecurityException::class); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $security->verify($request); + } + + public function testCSRFVerifyPostReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['foo'] = 'bar'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertTrue(count($_POST) === 1); + } + + public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); + + $security = new Security(new MockAppConfig()); + + $this->expectException(SecurityException::class); + $security->verify($request); + } + + public function testCSRFVerifyHeaderReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['foo'] = 'bar'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertCount(1, $_POST); + } + + public function testCSRFVerifyJsonThrowsExceptionOnNoMatch() + { + $this->expectException(SecurityException::class); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); + + $security = new Security(new MockAppConfig()); + + $security->verify($request); + } + + public function testCSRFVerifyJsonReturnsSelfOnMatch() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); + + $security = new Security(new MockAppConfig()); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertTrue($request->getBody() === '{"foo":"bar"}'); + } + + public function testRegenerateWithFalseSecurityRegenerateProperty() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $config = Factories::config('Security'); + $config->regenerate = false; + Factories::injectMock('config', 'Security', $config); + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $oldHash = $security->getHash(); + $security->verify($request); + $newHash = $security->getHash(); + + $this->assertSame($oldHash, $newHash); + } + + public function testRegenerateWithTrueSecurityRegenerateProperty() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; + + $config = Factories::config('Security'); + $config->regenerate = true; + Factories::injectMock('config', 'Security', $config); + + $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); + + $security = new Security(new MockAppConfig()); + + $oldHash = $security->getHash(); + $security->verify($request); + $newHash = $security->getHash(); + + $this->assertNotSame($oldHash, $newHash); + } +} diff --git a/user_guide_src/source/libraries/security.rst b/user_guide_src/source/libraries/security.rst index 9497723c4b46..d788e2d2a808 100644 --- a/user_guide_src/source/libraries/security.rst +++ b/user_guide_src/source/libraries/security.rst @@ -118,7 +118,23 @@ than simply crashing. This can be turned off by editing the following config par public $redirect = false; -Even when the redirect value is **true**, AJAX calls will not redirect, but will throw an error. +Even when the redirect value is ``true``, AJAX calls will not redirect, but will throw an error. + +======================= +CSRF Protection Methods +======================= + +By default, the Cookie based CSRF Protection is used. It is +`Double Submit Cookie `_ +on OWASP Cross-Site Request Forgery Prevention Cheat Sheet. + +You can also use Session based CSRF Protection. It is +`Synchronizer Token Pattern `_. + +You can set to use the Session based CSRF protection by editing the following config parameter value in +**app/Config/Security.php**:: + + public $csrfProtection = 'session'; ********************* Other Helpful Methods @@ -132,8 +148,8 @@ you might find helpful that are not related to the CSRF protection. Tries to sanitize filenames in order to prevent directory traversal attempts and other security threats, which is particularly useful for files that were supplied via user input. The first parameter is the path to sanitize. -If it is acceptable for the user input to include relative paths, e.g., file/in/some/approved/folder.txt, you can set -the second optional parameter, $relative_path to true. +If it is acceptable for the user input to include relative paths, e.g., **file/in/some/approved/folder.txt**, you can set +the second optional parameter, ``$relativePath`` to ``true``. :: $path = $security->sanitizeFilename($request->getVar('filepath'));