Skip to content

Commit cf0969a

Browse files
committed
Turn reCaptcha validation off only with the global disabled setting and read the request domain without a Referer header
1 parent 5518d06 commit cf0969a

6 files changed

Lines changed: 294 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Changelog
44
Releases for CakePHP 4.3
55
-------------
66

7+
* 9.1.11
8+
* 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.
9+
* 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.
10+
* `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.
11+
712
* 9.1.10
813
* Security: refreshing a JWT issued before the second factor was completed returned tokens that no longer required 2FA; refresh now preserves the token audience.
914
* 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.

‎composer.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
},
6363
"audit": {
6464
"ignore": {
65-
"CVE-2025-45769": "Low severity; firebase/php-jwt 7.x requires PHP 8.0 and this branch supports PHP 7.4"
65+
"CVE-2025-45769": "Low severity; firebase/php-jwt 7.x requires PHP 8.0 and this branch supports PHP 7.4",
66+
"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"
6667
}
6768
}
6869
},

‎src/Service/Action/Traits/ReCaptchaTrait.php‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CakeDC\Api\Service\Action\Traits;
1515

1616
use Cake\Core\Configure;
17+
use Cake\Log\Log;
1718
use CakeDC\Api\Utility\RequestParser;
1819
use ReCaptcha\ReCaptcha;
1920

@@ -32,13 +33,23 @@ trait ReCaptchaTrait
3233
*/
3334
public function validateReCaptcha($recaptchaResponse = null)
3435
{
35-
$domain = RequestParser::getDomain($this->getService()->getRequest());
36-
$reCaptcha = Configure::read('Api.reCaptcha.' . $domain);
37-
if ($reCaptcha['disabled'] ?? false) {
36+
if (Configure::read('Api.reCaptcha.disabled') === true) {
3837
return true;
3938
}
39+
// turning validation off is a global setting; a domain setting only logs a warning, without a
40+
// scope so that the application's default logs record it
41+
$domain = RequestParser::getDomain($this->getService()->getRequest());
42+
if (Configure::read('Api.reCaptcha.' . $domain . '.disabled')) {
43+
Log::warning(sprintf(
44+
'`Api.reCaptcha.%s.disabled` is ignored; set `Api.reCaptcha.disabled` to true instead',
45+
$domain
46+
));
47+
}
4048

4149
$recaptcha = $this->_getReCaptchaInstance();
50+
if ($recaptcha === null) {
51+
return false;
52+
}
4253
if ($recaptchaResponse === null) {
4354
$recaptchaResponse = $this->getService()->getRequest()->getData('g-recaptcha-response');
4455
}

‎src/Utility/RequestParser.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ public static function getDomain(ServerRequest $request, $replace = true)
2323
if (isset($_SERVER['HTTP_REFERER']) && $_SERVER['HTTP_REFERER']) {
2424
$domain = parse_url($_SERVER['HTTP_REFERER']);
2525
}
26-
if ($domain !==null && $domain['host']) {
26+
if (is_array($domain) && !empty($domain['host'])) {
2727
$host = $domain['host'];
2828
} else {
29-
$host = $this->request->domain();
29+
$host = $request->domain();
3030
}
3131

3232
if ($replace) {
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
11+
* @license MIT License (https://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\Api\Test\TestCase\Service\Action\Traits;
15+
16+
use Cake\Core\Configure;
17+
use Cake\Http\ServerRequest;
18+
use Cake\Log\Log;
19+
use Cake\TestSuite\TestCase;
20+
use CakeDC\Api\Service\Action\Traits\ReCaptchaTrait;
21+
22+
/**
23+
* Only the global setting turns reCaptcha validation off.
24+
*/
25+
class ReCaptchaTraitTest extends TestCase
26+
{
27+
public function tearDown(): void
28+
{
29+
Configure::delete('Api.reCaptcha');
30+
unset($_SERVER['HTTP_REFERER']);
31+
parent::tearDown();
32+
}
33+
34+
/**
35+
* @param bool|null $success Result of the verification, or null for no reCaptcha instance.
36+
* @return object
37+
*/
38+
protected function subject(?bool $success)
39+
{
40+
$request = new ServerRequest(['environment' => ['HTTP_HOST' => 'example.com']]);
41+
42+
return new class ($request, $success) {
43+
use ReCaptchaTrait;
44+
45+
public $request;
46+
47+
public $success;
48+
49+
public function __construct($request, $success)
50+
{
51+
$this->request = $request;
52+
$this->success = $success;
53+
}
54+
55+
public function getService()
56+
{
57+
$request = $this->request;
58+
59+
return new class ($request) {
60+
private $request;
61+
62+
public function __construct($request)
63+
{
64+
$this->request = $request;
65+
}
66+
67+
public function getRequest()
68+
{
69+
return $this->request;
70+
}
71+
};
72+
}
73+
74+
protected function _getReCaptchaInstance()
75+
{
76+
if ($this->success === null) {
77+
return null;
78+
}
79+
$success = $this->success;
80+
81+
return new class ($success) {
82+
private $success;
83+
84+
public function __construct($success)
85+
{
86+
$this->success = $success;
87+
}
88+
89+
public function verify($response, $ip)
90+
{
91+
$success = $this->success && $response === 'valid';
92+
93+
return new class ($success) {
94+
private $success;
95+
96+
public function __construct($success)
97+
{
98+
$this->success = $success;
99+
}
100+
101+
public function isSuccess()
102+
{
103+
return $this->success;
104+
}
105+
};
106+
}
107+
};
108+
}
109+
};
110+
}
111+
112+
public function testGlobalDisabledSkipsValidation(): void
113+
{
114+
Configure::write('Api.reCaptcha.disabled', true);
115+
$this->assertTrue($this->subject(false)->validateReCaptcha('any'));
116+
}
117+
118+
public function testValidTokenPasses(): void
119+
{
120+
$this->assertTrue($this->subject(true)->validateReCaptcha('valid'));
121+
$this->assertFalse($this->subject(true)->validateReCaptcha('invalid'));
122+
}
123+
124+
public function testDisabledForTheDomainStillValidates(): void
125+
{
126+
Configure::write('Api.reCaptcha.example$com', ['disabled' => true, 'secret' => 'secret']);
127+
$this->assertFalse($this->subject(true)->validateReCaptcha('invalid'));
128+
}
129+
130+
public function testDisabledForAnotherDomainStillValidates(): void
131+
{
132+
Configure::write('Api.reCaptcha.dev$test', ['disabled' => true]);
133+
$_SERVER['HTTP_REFERER'] = 'https://dev.test/';
134+
$this->assertFalse($this->subject(true)->validateReCaptcha('invalid'));
135+
}
136+
137+
public function testNoReCaptchaInstanceFails(): void
138+
{
139+
$this->assertFalse($this->subject(null)->validateReCaptcha('valid'));
140+
}
141+
142+
public function testGlobalDisabledMustBeTrue(): void
143+
{
144+
// a string from the environment does not turn validation off
145+
Configure::write('Api.reCaptcha.disabled', '1');
146+
$this->assertFalse($this->subject(true)->validateReCaptcha('invalid'));
147+
}
148+
149+
public function testNoSecretFails(): void
150+
{
151+
// the real lookup: no secret for the domain means no reCaptcha instance
152+
Configure::write('Api.reCaptcha.example$com', ['disabled' => true]);
153+
$request = new ServerRequest(['environment' => ['HTTP_HOST' => 'example.com']]);
154+
$subject = new class ($request) {
155+
use ReCaptchaTrait;
156+
157+
public $request;
158+
159+
public function __construct($request)
160+
{
161+
$this->request = $request;
162+
}
163+
164+
public function getService()
165+
{
166+
$request = $this->request;
167+
168+
return new class ($request) {
169+
private $request;
170+
171+
public function __construct($request)
172+
{
173+
$this->request = $request;
174+
}
175+
176+
public function getRequest()
177+
{
178+
return $this->request;
179+
}
180+
};
181+
}
182+
};
183+
184+
$this->assertFalse($subject->validateReCaptcha('valid'));
185+
}
186+
187+
public function testDisabledForTheDomainIsLogged(): void
188+
{
189+
// like the loggers of the application skeleton, which take messages without a scope
190+
Log::setConfig('recaptcha_test', ['className' => 'Array', 'levels' => ['warning'], 'scopes' => false]);
191+
Configure::write('Api.reCaptcha.example$com', ['disabled' => true]);
192+
try {
193+
$this->subject(true)->validateReCaptcha('invalid');
194+
$logged = implode("\n", Log::engine('recaptcha_test')->read());
195+
} finally {
196+
Log::drop('recaptcha_test');
197+
}
198+
199+
$this->assertStringContainsString('`Api.reCaptcha.example$com.disabled` is ignored', $logged);
200+
}
201+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2016 - 2026, Cake Development Corporation (https://cakedc.com)
11+
* @license MIT License (https://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\Api\Test\TestCase\Utility;
15+
16+
use Cake\Http\ServerRequest;
17+
use Cake\TestSuite\TestCase;
18+
use CakeDC\Api\Utility\RequestParser;
19+
20+
/**
21+
* The request domain comes from the Referer header, or from the request host.
22+
*/
23+
class RequestParserTest extends TestCase
24+
{
25+
public function tearDown(): void
26+
{
27+
unset($_SERVER['HTTP_REFERER']);
28+
parent::tearDown();
29+
}
30+
31+
/**
32+
* @return \Cake\Http\ServerRequest
33+
*/
34+
protected function request(): ServerRequest
35+
{
36+
return new ServerRequest(['environment' => ['HTTP_HOST' => 'api.example.com']]);
37+
}
38+
39+
public function testDomainOfTheRequestHost(): void
40+
{
41+
$this->assertSame('example$com', RequestParser::getDomain($this->request()));
42+
$this->assertSame('example.com', RequestParser::getDomain($this->request(), false));
43+
}
44+
45+
public function testDomainOfTheReferer(): void
46+
{
47+
$_SERVER['HTTP_REFERER'] = 'https://app.example.org/page';
48+
$this->assertSame('app$example$org', RequestParser::getDomain($this->request()));
49+
}
50+
51+
public function testRefererWithoutAHostUsesTheRequestHost(): void
52+
{
53+
$warnings = [];
54+
set_error_handler(function (int $level, string $message) use (&$warnings): bool {
55+
$warnings[] = $message;
56+
57+
return true;
58+
});
59+
try {
60+
foreach (['foo', 'http:///x'] as $referer) {
61+
$_SERVER['HTTP_REFERER'] = $referer;
62+
$this->assertSame('example$com', RequestParser::getDomain($this->request()), $referer);
63+
}
64+
} finally {
65+
restore_error_handler();
66+
}
67+
68+
$this->assertSame([], $warnings);
69+
}
70+
}

0 commit comments

Comments
 (0)