diff --git a/CHANGELOG.md b/CHANGELOG.md index 242b82e..f8575a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Changelog Releases for CakePHP 4.3 ------------- +* 9.1.11 + * reCaptcha: validation is turned off only by the new `Api.reCaptcha.disabled` setting, set to the boolean `true`. `Api.reCaptcha..disabled` is ignored and logs a warning. Without a configured secret for the domain, validation now fails instead of raising an error. + * Upgrade note: if you disabled reCaptcha for a domain, for example in a development config, set `Api.reCaptcha.disabled` to `true` there instead. A string such as `'true'` from an environment variable leaves validation on. Turn validation off with this setting, not with reCaptcha test keys for a domain. + * `RequestParser::getDomain()` no longer fails for a request without a `Referer` header, and no longer raises a PHP warning for a `Referer` header without a host. + * 9.1.10 * Security: refreshing a JWT issued before the second factor was completed returned tokens that no longer required 2FA; refresh now preserves the token audience. * Upgrade note: if 2FA is enabled, invalidate existing refresh tokens after upgrading, by emptying the `jwt_refresh_tokens` table or rotating `Api.Jwt.RefreshToken.secret`. Until you do, tokens that were already escalated before the upgrade keep their privileged audience: the access tokens stay valid for the rest of their lifetime, and their refresh tokens keep minting privileged ones. Rotating `Api.Jwt.AccessToken.secret` also ends current access tokens. diff --git a/composer.json b/composer.json index de19003..8fa6be6 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,8 @@ }, "audit": { "ignore": { - "CVE-2025-45769": "Low severity; firebase/php-jwt 7.x requires PHP 8.0 and this branch supports PHP 7.4" + "CVE-2025-45769": "Low severity; firebase/php-jwt 7.x requires PHP 8.0 and this branch supports PHP 7.4", + "CVE-2026-102601": "Development only: league/flysystem-vfs ^1.0 (used by the tests) needs league/flysystem 1.x, which this advisory covers; this affects installs of this package as the root project, not applications" } } }, diff --git a/src/Service/Action/Traits/ReCaptchaTrait.php b/src/Service/Action/Traits/ReCaptchaTrait.php index d19ddf2..9d7ba26 100644 --- a/src/Service/Action/Traits/ReCaptchaTrait.php +++ b/src/Service/Action/Traits/ReCaptchaTrait.php @@ -14,6 +14,7 @@ namespace CakeDC\Api\Service\Action\Traits; use Cake\Core\Configure; +use Cake\Log\Log; use CakeDC\Api\Utility\RequestParser; use ReCaptcha\ReCaptcha; @@ -32,13 +33,23 @@ trait ReCaptchaTrait */ public function validateReCaptcha($recaptchaResponse = null) { - $domain = RequestParser::getDomain($this->getService()->getRequest()); - $reCaptcha = Configure::read('Api.reCaptcha.' . $domain); - if ($reCaptcha['disabled'] ?? false) { + if (Configure::read('Api.reCaptcha.disabled') === true) { return true; } + // turning validation off is a global setting; a domain setting only logs a warning, without a + // scope so that the application's default logs record it + $domain = RequestParser::getDomain($this->getService()->getRequest()); + if (Configure::read('Api.reCaptcha.' . $domain . '.disabled')) { + Log::warning(sprintf( + '`Api.reCaptcha.%s.disabled` is ignored; set `Api.reCaptcha.disabled` to true instead', + $domain + )); + } $recaptcha = $this->_getReCaptchaInstance(); + if ($recaptcha === null) { + return false; + } if ($recaptchaResponse === null) { $recaptchaResponse = $this->getService()->getRequest()->getData('g-recaptcha-response'); } diff --git a/src/Utility/RequestParser.php b/src/Utility/RequestParser.php index 0d68b81..e8e86bd 100644 --- a/src/Utility/RequestParser.php +++ b/src/Utility/RequestParser.php @@ -23,10 +23,10 @@ public static function getDomain(ServerRequest $request, $replace = true) if (isset($_SERVER['HTTP_REFERER']) && $_SERVER['HTTP_REFERER']) { $domain = parse_url($_SERVER['HTTP_REFERER']); } - if ($domain !==null && $domain['host']) { + if (is_array($domain) && !empty($domain['host'])) { $host = $domain['host']; } else { - $host = $this->request->domain(); + $host = $request->domain(); } if ($replace) { diff --git a/tests/TestCase/Service/Action/Traits/ReCaptchaTraitTest.php b/tests/TestCase/Service/Action/Traits/ReCaptchaTraitTest.php new file mode 100644 index 0000000..3408beb --- /dev/null +++ b/tests/TestCase/Service/Action/Traits/ReCaptchaTraitTest.php @@ -0,0 +1,201 @@ + ['HTTP_HOST' => 'example.com']]); + + return new class ($request, $success) { + use ReCaptchaTrait; + + public $request; + + public $success; + + public function __construct($request, $success) + { + $this->request = $request; + $this->success = $success; + } + + public function getService() + { + $request = $this->request; + + return new class ($request) { + private $request; + + public function __construct($request) + { + $this->request = $request; + } + + public function getRequest() + { + return $this->request; + } + }; + } + + protected function _getReCaptchaInstance() + { + if ($this->success === null) { + return null; + } + $success = $this->success; + + return new class ($success) { + private $success; + + public function __construct($success) + { + $this->success = $success; + } + + public function verify($response, $ip) + { + $success = $this->success && $response === 'valid'; + + return new class ($success) { + private $success; + + public function __construct($success) + { + $this->success = $success; + } + + public function isSuccess() + { + return $this->success; + } + }; + } + }; + } + }; + } + + public function testGlobalDisabledSkipsValidation(): void + { + Configure::write('Api.reCaptcha.disabled', true); + $this->assertTrue($this->subject(false)->validateReCaptcha('any')); + } + + public function testValidTokenPasses(): void + { + $this->assertTrue($this->subject(true)->validateReCaptcha('valid')); + $this->assertFalse($this->subject(true)->validateReCaptcha('invalid')); + } + + public function testDisabledForTheDomainStillValidates(): void + { + Configure::write('Api.reCaptcha.example$com', ['disabled' => true, 'secret' => 'secret']); + $this->assertFalse($this->subject(true)->validateReCaptcha('invalid')); + } + + public function testDisabledForAnotherDomainStillValidates(): void + { + Configure::write('Api.reCaptcha.dev$test', ['disabled' => true]); + $_SERVER['HTTP_REFERER'] = 'https://dev.test/'; + $this->assertFalse($this->subject(true)->validateReCaptcha('invalid')); + } + + public function testNoReCaptchaInstanceFails(): void + { + $this->assertFalse($this->subject(null)->validateReCaptcha('valid')); + } + + public function testGlobalDisabledMustBeTrue(): void + { + // a string from the environment does not turn validation off + Configure::write('Api.reCaptcha.disabled', '1'); + $this->assertFalse($this->subject(true)->validateReCaptcha('invalid')); + } + + public function testNoSecretFails(): void + { + // the real lookup: no secret for the domain means no reCaptcha instance + Configure::write('Api.reCaptcha.example$com', ['disabled' => true]); + $request = new ServerRequest(['environment' => ['HTTP_HOST' => 'example.com']]); + $subject = new class ($request) { + use ReCaptchaTrait; + + public $request; + + public function __construct($request) + { + $this->request = $request; + } + + public function getService() + { + $request = $this->request; + + return new class ($request) { + private $request; + + public function __construct($request) + { + $this->request = $request; + } + + public function getRequest() + { + return $this->request; + } + }; + } + }; + + $this->assertFalse($subject->validateReCaptcha('valid')); + } + + public function testDisabledForTheDomainIsLogged(): void + { + // like the loggers of the application skeleton, which take messages without a scope + Log::setConfig('recaptcha_test', ['className' => 'Array', 'levels' => ['warning'], 'scopes' => false]); + Configure::write('Api.reCaptcha.example$com', ['disabled' => true]); + try { + $this->subject(true)->validateReCaptcha('invalid'); + $logged = implode("\n", Log::engine('recaptcha_test')->read()); + } finally { + Log::drop('recaptcha_test'); + } + + $this->assertStringContainsString('`Api.reCaptcha.example$com.disabled` is ignored', $logged); + } +} diff --git a/tests/TestCase/Utility/RequestParserTest.php b/tests/TestCase/Utility/RequestParserTest.php new file mode 100644 index 0000000..cc48c5b --- /dev/null +++ b/tests/TestCase/Utility/RequestParserTest.php @@ -0,0 +1,70 @@ + ['HTTP_HOST' => 'api.example.com']]); + } + + public function testDomainOfTheRequestHost(): void + { + $this->assertSame('example$com', RequestParser::getDomain($this->request())); + $this->assertSame('example.com', RequestParser::getDomain($this->request(), false)); + } + + public function testDomainOfTheReferer(): void + { + $_SERVER['HTTP_REFERER'] = 'https://app.example.org/page'; + $this->assertSame('app$example$org', RequestParser::getDomain($this->request())); + } + + public function testRefererWithoutAHostUsesTheRequestHost(): void + { + $warnings = []; + set_error_handler(function (int $level, string $message) use (&$warnings): bool { + $warnings[] = $message; + + return true; + }); + try { + foreach (['foo', 'http:///x'] as $referer) { + $_SERVER['HTTP_REFERER'] = $referer; + $this->assertSame('example$com', RequestParser::getDomain($this->request()), $referer); + } + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings); + } +}