Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<domain>.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.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
17 changes: 14 additions & 3 deletions src/Service/Action/Traits/ReCaptchaTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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');
}
Expand Down
4 changes: 2 additions & 2 deletions src/Utility/RequestParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
201 changes: 201 additions & 0 deletions tests/TestCase/Service/Action/Traits/ReCaptchaTraitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);

/**
* Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
* @license MIT License (https://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\Api\Test\TestCase\Service\Action\Traits;

use Cake\Core\Configure;
use Cake\Http\ServerRequest;
use Cake\Log\Log;
use Cake\TestSuite\TestCase;
use CakeDC\Api\Service\Action\Traits\ReCaptchaTrait;

/**
* Only the global setting turns reCaptcha validation off.
*/
class ReCaptchaTraitTest extends TestCase
{
public function tearDown(): void
{
Configure::delete('Api.reCaptcha');
unset($_SERVER['HTTP_REFERER']);
parent::tearDown();
}

/**
* @param bool|null $success Result of the verification, or null for no reCaptcha instance.
* @return object
*/
protected function subject(?bool $success)
{
$request = new ServerRequest(['environment' => ['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);
}
}
70 changes: 70 additions & 0 deletions tests/TestCase/Utility/RequestParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);

/**
* Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
* @license MIT License (https://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\Api\Test\TestCase\Utility;

use Cake\Http\ServerRequest;
use Cake\TestSuite\TestCase;
use CakeDC\Api\Utility\RequestParser;

/**
* The request domain comes from the Referer header, or from the request host.
*/
class RequestParserTest extends TestCase
{
public function tearDown(): void
{
unset($_SERVER['HTTP_REFERER']);
parent::tearDown();
}

/**
* @return \Cake\Http\ServerRequest
*/
protected function request(): ServerRequest
{
return new ServerRequest(['environment' => ['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);
}
}
Loading