Use the reCAPTCHA Enterprise API.
This commit is contained in:
+7
-2
@@ -13,10 +13,15 @@ DB_NAME=xxx
|
||||
# host. If it is empty, the pages refuse plain HTTP.
|
||||
SITE_URL=https://xxx.org.za
|
||||
|
||||
# Google reCAPTCHA v2 keys (https://www.google.com/recaptcha/admin).
|
||||
# Google reCAPTCHA Enterprise checkbox key and its Google Cloud project ID.
|
||||
# Enable the reCAPTCHA Enterprise API in that project. RECAPTCHA_API_KEY is
|
||||
# a server-side Google Cloud API key, not the old reCAPTCHA secret. Restrict
|
||||
# it to that API and, if possible, the server's outbound IP address.
|
||||
# SITE_URL must have the same hostname as the page that shows the checkbox.
|
||||
# Leave RECAPTCHA_SITE_KEY empty to turn the captcha off.
|
||||
RECAPTCHA_SITE_KEY=
|
||||
RECAPTCHA_SECRET=
|
||||
RECAPTCHA_PROJECT_ID=
|
||||
RECAPTCHA_API_KEY=
|
||||
|
||||
# Google Analytics tracking ID. Leave it empty to turn analytics off.
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
@@ -9,6 +9,21 @@ fancy new PHP features... that's probably why.
|
||||
The site reads its configuration from `.env`. Copy `.env.example` to
|
||||
`.env`, and set the values in it.
|
||||
|
||||
## reCAPTCHA
|
||||
|
||||
To enable reCAPTCHA, set up the Google Cloud credentials:
|
||||
|
||||
1. Enable the reCAPTCHA Enterprise API in the project that owns your
|
||||
checkbox key.
|
||||
2. Set `RECAPTCHA_SITE_KEY` to that checkbox key in `.env`.
|
||||
3. Set `RECAPTCHA_PROJECT_ID` to the Google Cloud project ID.
|
||||
4. Create a Google Cloud API key for server requests.
|
||||
5. Restrict the API key to the reCAPTCHA Enterprise API.
|
||||
6. If the server has a fixed outbound IP address, restrict the API key
|
||||
to that address too.
|
||||
7. Set `RECAPTCHA_API_KEY` to the API key in `.env`.
|
||||
8. Set `SITE_URL` to the site's HTTPS address.
|
||||
|
||||
## Tests
|
||||
|
||||
The repository has three sets of tests:
|
||||
|
||||
+55
-26
@@ -1,12 +1,11 @@
|
||||
<?php
|
||||
/*
|
||||
* Google reCAPTCHA v2. The keys come from RECAPTCHA_SITE_KEY and
|
||||
* RECAPTCHA_SECRET in .env.
|
||||
* Google reCAPTCHA Enterprise, with a checkbox key. Configuration comes
|
||||
* from RECAPTCHA_SITE_KEY, RECAPTCHA_PROJECT_ID and RECAPTCHA_API_KEY.
|
||||
* SITE_URL supplies the hostname that a response must match.
|
||||
*
|
||||
* Without RECAPTCHA_SITE_KEY the captcha is off: the pages load nothing
|
||||
* from Google and every check passes. With the site key but without the
|
||||
* secret, or when Google cannot be reached, every check fails (and the
|
||||
* reason is logged).
|
||||
* Without RECAPTCHA_SITE_KEY the captcha is off. With a site key, missing
|
||||
* configuration or a failed assessment blocks the request.
|
||||
*/
|
||||
require_once(__DIR__ . '/config.lib.php');
|
||||
require_once(__DIR__ . '/html.lib.php');
|
||||
@@ -20,44 +19,74 @@ function captcha_enabled() {
|
||||
function captcha_script() {
|
||||
if (!captcha_enabled())
|
||||
return '';
|
||||
return '<script src="https://www.google.com/recaptcha/api.js" async defer></script>';
|
||||
return '<script src="https://www.google.com/recaptcha/enterprise.js" async defer></script>';
|
||||
}
|
||||
|
||||
function captcha_widget() {
|
||||
function captcha_widget($action = 'submit') {
|
||||
if (!captcha_enabled())
|
||||
return '';
|
||||
return '<div class="g-recaptcha" data-sitekey="'.form_value(config('RECAPTCHA_SITE_KEY')).'"></div>';
|
||||
return '<div class="g-recaptcha" data-sitekey="'.form_value(config('RECAPTCHA_SITE_KEY')).'" data-action="'.form_value($action).'"></div>';
|
||||
}
|
||||
|
||||
// Ask Google whether the captcha in this POST was solved
|
||||
function captcha_verify_response() {
|
||||
// The expected action comes from the page, never from the request
|
||||
function captcha_verify_response($action = 'submit') {
|
||||
if (!captcha_enabled())
|
||||
return TRUE;
|
||||
$response = $_POST['g-recaptcha-response'] ?? '';
|
||||
if (!is_string($response) || $response === '')
|
||||
return FALSE;
|
||||
$secret = (string)config('RECAPTCHA_SECRET');
|
||||
if ($secret === '') {
|
||||
error_log('xxx: RECAPTCHA_SECRET is not set, so every captcha check fails');
|
||||
$project = trim((string)config('RECAPTCHA_PROJECT_ID'));
|
||||
$api_key = trim((string)config('RECAPTCHA_API_KEY'));
|
||||
$host = strtolower(site_host());
|
||||
if ($project === '' || $api_key === '' || $host === '') {
|
||||
error_log('xxx: reCAPTCHA needs RECAPTCHA_PROJECT_ID, RECAPTCHA_API_KEY and a SITE_URL with a host');
|
||||
return FALSE;
|
||||
}
|
||||
$context = stream_context_create(array('http' => array(
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => http_build_query(array(
|
||||
'secret' => $secret,
|
||||
'response' => $response,
|
||||
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
)),
|
||||
'timeout' => 10,
|
||||
if (preg_match('/[\r\n]/', $api_key)) {
|
||||
error_log('xxx: RECAPTCHA_API_KEY contains a line break');
|
||||
return FALSE;
|
||||
}
|
||||
$body = json_encode(array('event' => array(
|
||||
'token' => $response,
|
||||
'siteKey' => (string)config('RECAPTCHA_SITE_KEY'),
|
||||
'expectedAction' => $action,
|
||||
'userIpAddress' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
)));
|
||||
$reply = @file_get_contents('https://www.google.com/recaptcha/api/siteverify', FALSE, $context);
|
||||
if ($body === FALSE)
|
||||
return FALSE;
|
||||
// Keep the API key out of URLs and logs, and do not forward it on redirects
|
||||
$context = stream_context_create(array('http' => array(
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nX-Goog-Api-Key: $api_key\r\n",
|
||||
'content' => $body,
|
||||
'timeout' => 10,
|
||||
'follow_location' => 0,
|
||||
'ignore_errors' => FALSE,
|
||||
)));
|
||||
$url = 'https://recaptchaenterprise.googleapis.com/v1/projects/'.rawurlencode($project).'/assessments';
|
||||
$reply = @file_get_contents($url, FALSE, $context);
|
||||
if ($reply === FALSE) {
|
||||
error_log('xxx: could not reach the reCAPTCHA server');
|
||||
error_log('xxx: reCAPTCHA assessment request failed');
|
||||
return FALSE;
|
||||
}
|
||||
$result = json_decode($reply);
|
||||
return is_object($result) && !empty($result->success);
|
||||
if (!is_object($result) || isset($result->error) || !is_object($result->tokenProperties ?? NULL)) {
|
||||
error_log('xxx: reCAPTCHA returned an invalid assessment or an API error');
|
||||
return FALSE;
|
||||
}
|
||||
$token = $result->tokenProperties;
|
||||
if (($token->valid ?? FALSE) !== TRUE)
|
||||
return FALSE;
|
||||
if (!is_string($token->hostname ?? NULL) || strtolower($token->hostname) !== $host) {
|
||||
error_log('xxx: reCAPTCHA hostname did not match SITE_URL');
|
||||
return FALSE;
|
||||
}
|
||||
if (($token->action ?? NULL) !== $action) {
|
||||
error_log('xxx: reCAPTCHA action did not match the page');
|
||||
return FALSE;
|
||||
}
|
||||
// Keep checkbox behavior; do not reject visitors by a risk score
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ if (isset($_SESSION['editor'])) {
|
||||
} else if (isset($_POST['login']) && ($wait = login_blocked_for($login_name)) > 0) {
|
||||
$denied = TRUE;
|
||||
$wait_minutes = (int)ceil($wait / 60);
|
||||
} else if (isset($_POST['login']) && !captcha_verify_response()) {
|
||||
} else if (isset($_POST['login']) && !captcha_verify_response('login')) {
|
||||
$denied = TRUE;
|
||||
$captcha_failed = TRUE;
|
||||
} else if (isset($_POST['login'])) {
|
||||
@@ -113,7 +113,7 @@ else if ($denied)
|
||||
<input type="checkbox" id="remember" name="remember" checked />
|
||||
</div>
|
||||
|
||||
<?= captcha_widget() ?>
|
||||
<?= captcha_widget('login') ?>
|
||||
|
||||
<div class="leftcolumn"></div>
|
||||
<div class="submission">
|
||||
|
||||
@@ -50,7 +50,8 @@ class LoginTest extends PageTestCase {
|
||||
public function testTheFormHasATokenAndTheCaptcha(): void {
|
||||
$body = $this->get('/publish/login')['body'];
|
||||
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($body));
|
||||
$this->assertStringContainsString('data-sitekey="test-site-key"', $body);
|
||||
$this->assertStringContainsString('data-sitekey="test-site-key" data-action="login"', $body);
|
||||
$this->assertStringContainsString('https://www.google.com/recaptcha/enterprise.js', $body);
|
||||
}
|
||||
|
||||
public function testTheRightPasswordLogsIn(): void {
|
||||
@@ -130,6 +131,18 @@ class LoginTest extends PageTestCase {
|
||||
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
||||
}
|
||||
|
||||
public static function failedAssessments(): array {
|
||||
return array(array('wrong-host'), array('wrong-action'), array('api-error'), array('unreachable'));
|
||||
}
|
||||
|
||||
#[DataProvider('failedAssessments')]
|
||||
public function testAFailedAssessmentBlocksTheLogin(string $response): void {
|
||||
$answer = $this->login('secret', array('g-recaptcha-response' => $response));
|
||||
$this->assertStringContainsString('Please complete the captcha.', $answer['body']);
|
||||
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
||||
$this->assertArrayNotHasKey('editor', $this->session());
|
||||
}
|
||||
|
||||
public function testFiveFailuresBlockTheLogin(): void {
|
||||
for ($i = 0; $i < 5; $i++)
|
||||
$this->login('wrong');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
use Xxx\Tests\Support\TestCase;
|
||||
|
||||
@@ -24,8 +25,8 @@ class SubmitTest extends PageTestCase {
|
||||
$page = $this->get('/publish');
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($page['body']));
|
||||
$this->assertStringContainsString('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', $page['body']);
|
||||
$this->assertStringContainsString('https://www.google.com/recaptcha/api.js', $page['body']);
|
||||
$this->assertStringContainsString('<div class="g-recaptcha" data-sitekey="test-site-key" data-action="submit"></div>', $page['body']);
|
||||
$this->assertStringContainsString('https://www.google.com/recaptcha/enterprise.js', $page['body']);
|
||||
$this->assertStringContainsString('<script type="text/javascript" src="../publish/editor.js"></script>', $page['body']);
|
||||
}
|
||||
|
||||
@@ -104,6 +105,20 @@ class SubmitTest extends PageTestCase {
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
}
|
||||
|
||||
public static function failedAssessments(): array {
|
||||
return array(array('wrong-host'), array('wrong-action'), array('api-error'), array('unreachable'));
|
||||
}
|
||||
|
||||
#[DataProvider('failedAssessments')]
|
||||
public function testAFailedAssessmentBlocksTheSubmission(string $response): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$page = $this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => $response)));
|
||||
$this->assertStringContainsString('Please complete the captcha.', $page['body']);
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
$this->assertSame(array(), $this->mails());
|
||||
$this->assertArrayNotHasKey('captcha_ok', $this->session());
|
||||
}
|
||||
|
||||
public function testMissingFieldsAreShown(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$page = $this->post('/publish', array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass', 'story' => '<p>x</p>'));
|
||||
|
||||
@@ -10,15 +10,16 @@ namespace Xxx\Tests\Support;
|
||||
* returns one of these.
|
||||
*/
|
||||
class FakeRecaptcha {
|
||||
public const PASS = '{"tokenProperties":{"valid":true,"hostname":"site.test","action":"submit"}}';
|
||||
public static array $requests = array();
|
||||
public static $reply = '{"success": true}';
|
||||
public static $reply = self::PASS;
|
||||
private static bool $active = FALSE;
|
||||
|
||||
public $context;
|
||||
private string $data = '';
|
||||
private int $position = 0;
|
||||
|
||||
public static function install($reply = '{"success": true}'): void {
|
||||
public static function install($reply = self::PASS): void {
|
||||
self::$requests = array();
|
||||
self::$reply = $reply;
|
||||
if (!self::$active) {
|
||||
@@ -38,7 +39,7 @@ class FakeRecaptcha {
|
||||
public function stream_open($path, $mode, $options, &$opened_path) {
|
||||
$context = $this->context ? stream_context_get_options($this->context) : array();
|
||||
$http = $context['http'] ?? array();
|
||||
parse_str((string)($http['content'] ?? ''), $post);
|
||||
$post = json_decode((string)($http['content'] ?? ''), TRUE) ?? array();
|
||||
self::$requests[] = array('url' => $path, 'http' => $http, 'post' => $post);
|
||||
$reply = self::$reply instanceof \Closure ? (self::$reply)($post) : self::$reply;
|
||||
if ($reply === FALSE)
|
||||
|
||||
+133
-17
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Xxx\Tests\Support\FakeRecaptcha;
|
||||
use Xxx\Tests\Support\TestCase;
|
||||
|
||||
@@ -17,13 +18,19 @@ class CaptchaTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testScriptAndWidget(): void {
|
||||
$this->assertSame('<script src="https://www.google.com/recaptcha/api.js" async defer></script>', captcha_script());
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', captcha_widget());
|
||||
$this->assertSame('<script src="https://www.google.com/recaptcha/enterprise.js" async defer></script>', captcha_script());
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key" data-action="submit"></div>', captcha_widget());
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key" data-action="login"></div>', captcha_widget('login'));
|
||||
}
|
||||
|
||||
public function testWidgetEscapesTheSiteKey(): void {
|
||||
$this->setEnv('RECAPTCHA_SITE_KEY', '"><script>x</script>');
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey=""><script>x</script>"></div>', captcha_widget());
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey=""><script>x</script>" data-action="submit"></div>', captcha_widget());
|
||||
}
|
||||
|
||||
public function testWidgetEscapesTheAction(): void {
|
||||
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key" data-action=""><script>x</script>"></div>',
|
||||
captcha_widget('"><script>x</script>'));
|
||||
}
|
||||
|
||||
public function testNothingFromGoogleWithoutASiteKey(): void {
|
||||
@@ -51,55 +58,164 @@ class CaptchaTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testSolvedCaptchaPasses(): void {
|
||||
FakeRecaptcha::install('{"success": true, "hostname": "xxx.org.za"}');
|
||||
FakeRecaptcha::install();
|
||||
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
|
||||
$this->assertTrue(captcha_verify_response());
|
||||
}
|
||||
|
||||
// The secret goes in the POST body, never in the URL
|
||||
public function testRequestSendsTheSecretByPost(): void {
|
||||
// The API key goes in a header, never in the URL or the JSON body
|
||||
public function testRequestCreatesAnEnterpriseAssessment(): void {
|
||||
FakeRecaptcha::install();
|
||||
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
|
||||
captcha_verify_response();
|
||||
$this->assertCount(1, FakeRecaptcha::$requests);
|
||||
$request = FakeRecaptcha::$requests[0];
|
||||
$this->assertSame('https://www.google.com/recaptcha/api/siteverify', $request['url']);
|
||||
$this->assertSame('https://recaptchaenterprise.googleapis.com/v1/projects/test-project/assessments', $request['url']);
|
||||
$this->assertSame('POST', $request['http']['method']);
|
||||
$this->assertSame(array('secret' => 'test-secret', 'response' => 'token-from-widget', 'remoteip' => '198.51.100.7'), $request['post']);
|
||||
$this->assertStringContainsString('application/x-www-form-urlencoded', $request['http']['header']);
|
||||
$this->assertSame(array('event' => array('token' => 'token-from-widget', 'siteKey' => 'test-site-key',
|
||||
'expectedAction' => 'submit', 'userIpAddress' => '198.51.100.7')), $request['post']);
|
||||
$this->assertStringContainsString('Content-Type: application/json', $request['http']['header']);
|
||||
$this->assertStringContainsString('X-Goog-Api-Key: test-api-key', $request['http']['header']);
|
||||
$this->assertStringNotContainsString('test-api-key', $request['url'] . $request['http']['content']);
|
||||
$this->assertSame(10, $request['http']['timeout']);
|
||||
$this->assertSame(0, $request['http']['follow_location']);
|
||||
$this->assertFalse($request['http']['ignore_errors']);
|
||||
}
|
||||
|
||||
public function testUnsolvedCaptchaFails(): void {
|
||||
FakeRecaptcha::install('{"success": false, "error-codes": ["invalid-input-response"]}');
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":false,"invalidReason":"DUPE"}}');
|
||||
$this->request(array(), array('g-recaptcha-response' => 'bad'));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testOddRepliesFail(): void {
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
foreach (array('', 'not json', '[]', '"success"', '{"success": 0}', '{}') as $reply) {
|
||||
foreach (array('', 'not json', '[]', '"success"', 'null', 'true', '{"success":true}', '{}',
|
||||
'{"tokenProperties":[]}', '{"tokenProperties":true}', '{"tokenProperties":"valid"}',
|
||||
'{"tokenProperties":{}}') as $reply) {
|
||||
FakeRecaptcha::install($reply);
|
||||
$this->assertFalse(captcha_verify_response(), $reply);
|
||||
$this->assertFalse(captcha_check(), $reply);
|
||||
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
|
||||
}
|
||||
}
|
||||
|
||||
public function testOnlyBooleanTrueIsAValidToken(): void {
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
foreach (array(FALSE, NULL, 0, 1, 'true', 'false', array(TRUE)) as $valid) {
|
||||
FakeRecaptcha::install(json_encode(array('tokenProperties' => array('valid' => $valid, 'hostname' => 'site.test', 'action' => 'submit'))));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
}
|
||||
}
|
||||
|
||||
public function testHostnameMustMatchConfigurationNotTheRequestHost(): void {
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$_SERVER['HTTP_HOST'] = 'evil.example';
|
||||
try {
|
||||
foreach (array(NULL, '', array('site.test'), 'evil.example', 'sub.site.test', 'site.test.evil.example') as $host) {
|
||||
FakeRecaptcha::install(json_encode(array('tokenProperties' => array('valid' => TRUE, 'hostname' => $host, 'action' => 'submit'))));
|
||||
$this->assertFalse(captcha_check());
|
||||
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
|
||||
}
|
||||
FakeRecaptcha::install();
|
||||
$this->assertTrue(captcha_verify_response());
|
||||
} finally {
|
||||
unset($_SERVER['HTTP_HOST']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testHostnameIgnoresCaseAndTheConfiguredPort(): void {
|
||||
$this->setEnv('SITE_URL', 'https://SITE.TEST:8443');
|
||||
FakeRecaptcha::install();
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertTrue(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testMissingHostnameFails(): void {
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":true,"action":"submit"}}');
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testActionMustMatchThePage(): void {
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token', 'action' => 'login'));
|
||||
foreach (array(NULL, '', 'login', array('submit')) as $action) {
|
||||
FakeRecaptcha::install(json_encode(array('tokenProperties' => array('valid' => TRUE, 'hostname' => 'site.test', 'action' => $action))));
|
||||
$this->assertFalse(captcha_check());
|
||||
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
|
||||
}
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":true,"hostname":"site.test"}}');
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testLoginHasItsOwnAction(): void {
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":true,"hostname":"site.test","action":"login"}}');
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertTrue(captcha_verify_response('login'));
|
||||
$this->assertSame('login', FakeRecaptcha::$requests[0]['post']['event']['expectedAction']);
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testALowScoreDoesNotChangeCheckboxBehavior(): void {
|
||||
$reply = json_decode(FakeRecaptcha::PASS, TRUE);
|
||||
$reply['riskAnalysis'] = array('score' => 0.1);
|
||||
FakeRecaptcha::install(json_encode($reply));
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertTrue(captcha_verify_response());
|
||||
}
|
||||
|
||||
public function testUnreachableServerFailsAndIsLogged(): void {
|
||||
FakeRecaptcha::install(FALSE);
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
$this->assertStringContainsString('xxx: could not reach the reCAPTCHA server', $this->loggedErrors());
|
||||
$this->assertFalse(captcha_check());
|
||||
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
|
||||
$this->assertStringContainsString('xxx: reCAPTCHA assessment request failed', $this->loggedErrors());
|
||||
}
|
||||
|
||||
public function testMissingSecretFailsAndIsLogged(): void {
|
||||
$this->setEnv('RECAPTCHA_SECRET', '');
|
||||
public function testApiErrorsFailWithoutLoggingSecretsOrTheReply(): void {
|
||||
$this->request(array(), array('g-recaptcha-response' => 'private-token'));
|
||||
foreach (array(400, 401, 403, 429, 500, 503) as $code) {
|
||||
// Reject an error even if the response also claims to have a valid token
|
||||
$reply = json_decode(FakeRecaptcha::PASS, TRUE);
|
||||
$reply['error'] = array('code' => $code, 'message' => 'test-api-key private-token');
|
||||
FakeRecaptcha::install(json_encode($reply));
|
||||
$this->assertFalse(captcha_check());
|
||||
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
|
||||
}
|
||||
$log = $this->loggedErrors();
|
||||
$this->assertStringContainsString('API error', $log);
|
||||
$this->assertStringNotContainsString('test-api-key', $log);
|
||||
$this->assertStringNotContainsString('private-token', $log);
|
||||
}
|
||||
|
||||
public static function requiredSettings(): array {
|
||||
return array(array('RECAPTCHA_PROJECT_ID'), array('RECAPTCHA_API_KEY'), array('SITE_URL'));
|
||||
}
|
||||
|
||||
#[DataProvider('requiredSettings')]
|
||||
public function testMissingConfigurationFailsAndIsLogged(string $name): void {
|
||||
$this->setEnv($name, '');
|
||||
FakeRecaptcha::install();
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
$this->assertSame(array(), FakeRecaptcha::$requests);
|
||||
$this->assertStringContainsString('RECAPTCHA_SECRET is not set', $this->loggedErrors());
|
||||
$this->assertStringContainsString($name, $this->loggedErrors());
|
||||
}
|
||||
|
||||
public function testAnApiKeyCannotInjectHeaders(): void {
|
||||
$this->setEnv('RECAPTCHA_API_KEY', "test-api-key\r\nInjected: value");
|
||||
FakeRecaptcha::install();
|
||||
$this->request(array(), array('g-recaptcha-response' => 'token'));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
$this->assertSame(array(), FakeRecaptcha::$requests);
|
||||
}
|
||||
|
||||
public function testInvalidUtf8InTheTokenMakesNoRequest(): void {
|
||||
FakeRecaptcha::install();
|
||||
$this->request(array(), array('g-recaptcha-response' => "\xff"));
|
||||
$this->assertFalse(captcha_verify_response());
|
||||
$this->assertSame(array(), FakeRecaptcha::$requests);
|
||||
}
|
||||
|
||||
public function testPassLastsHalfAnHour(): void {
|
||||
|
||||
@@ -35,24 +35,24 @@ class ValidateDataTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testVisitorsNeedTheCaptcha(): void {
|
||||
FakeRecaptcha::install('{"success": false}');
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":false}}');
|
||||
$this->assertSame(array('captcha' => 'Please complete the captcha.'), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
|
||||
}
|
||||
|
||||
public function testASolvedCaptchaIsRemembered(): void {
|
||||
FakeRecaptcha::install('{"success": true}');
|
||||
FakeRecaptcha::install();
|
||||
$this->assertSame(array(), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
|
||||
$this->assertGreaterThan(time() - 5, $_SESSION['captcha_ok']);
|
||||
}
|
||||
|
||||
public function testAnOldCaptchaPassDoesNotCount(): void {
|
||||
FakeRecaptcha::install('{"success": false}');
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":false}}');
|
||||
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1;
|
||||
$this->assertArrayHasKey('captcha', $this->validate(array(), FALSE));
|
||||
}
|
||||
|
||||
public function testEditorsNeedNoCaptcha(): void {
|
||||
FakeRecaptcha::install('{"success": false}');
|
||||
FakeRecaptcha::install('{"tokenProperties":{"valid":false}}');
|
||||
$this->asEditor();
|
||||
$this->assertSame(array(), $this->validate(array(), FALSE));
|
||||
$this->assertSame(array(), FakeRecaptcha::$requests);
|
||||
|
||||
@@ -41,8 +41,18 @@ function xxx_page_route() {
|
||||
file_put_contents("$state/tables.ser", serialize(array('tables' => SiteData::$tables,
|
||||
'passwordLength' => SiteData::$passwordLength)), LOCK_EX);
|
||||
});
|
||||
FakeRecaptcha::install(function ($post) {
|
||||
return json_encode(array('success' => ($post['response'] ?? '') === 'pass'));
|
||||
FakeRecaptcha::install(function ($post) use ($file) {
|
||||
$token = $post['event']['token'] ?? '';
|
||||
if ($token === 'unreachable')
|
||||
return FALSE;
|
||||
if ($token === 'api-error')
|
||||
return '{"error":{"code":403,"status":"PERMISSION_DENIED"}}';
|
||||
$action = $file === XXX_ROOT . '/publish/login.php' ? 'login' : 'submit';
|
||||
return json_encode(array('tokenProperties' => array(
|
||||
'valid' => in_array($token, array('pass', 'wrong-host', 'wrong-action'), TRUE),
|
||||
'hostname' => $token === 'wrong-host' ? 'evil.example' : 'site.test',
|
||||
'action' => $token === 'wrong-action' ? 'other' : $action,
|
||||
)));
|
||||
});
|
||||
|
||||
if (!isset($_SERVER['HTTP_X_TEST_PLAIN']))
|
||||
|
||||
@@ -5,7 +5,8 @@ DB_PASS=test-pass
|
||||
DB_NAME=test-db
|
||||
SITE_URL=https://site.test
|
||||
RECAPTCHA_SITE_KEY=test-site-key
|
||||
RECAPTCHA_SECRET=test-secret
|
||||
RECAPTCHA_PROJECT_ID=test-project
|
||||
RECAPTCHA_API_KEY=test-api-key
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
UPLOAD_DIR=
|
||||
UPLOAD_DIR_LIMIT_MB=1
|
||||
|
||||
Reference in New Issue
Block a user