setEnv('RECAPTCHA_SITE_KEY', ''); } public function testEnabledWithASiteKey(): void { $this->assertTrue(captcha_enabled()); $this->disable(); $this->assertFalse(captcha_enabled()); } public function testScriptAndWidget(): void { $this->assertSame('', captcha_script()); $this->assertSame('
', captcha_widget()); $this->assertSame('', captcha_widget('login')); } public function testWidgetEscapesTheSiteKey(): void { $this->setEnv('RECAPTCHA_SITE_KEY', '">'); $this->assertSame('', captcha_widget()); } public function testWidgetEscapesTheAction(): void { $this->assertSame('', captcha_widget('">')); } public function testNothingFromGoogleWithoutASiteKey(): void { $this->disable(); $this->assertSame('', captcha_script()); $this->assertSame('', captcha_widget()); } public function testEveryCheckPassesWithoutASiteKey(): void { $this->disable(); FakeRecaptcha::install(FALSE); $this->assertTrue(captcha_verify_response()); $this->assertTrue(captcha_check()); $this->assertSame(array(), FakeRecaptcha::$requests); } public function testNoResponseFailsWithoutAskingGoogle(): void { FakeRecaptcha::install(); $this->assertFalse(captcha_verify_response()); $this->request(array(), array('g-recaptcha-response' => '')); $this->assertFalse(captcha_verify_response()); $this->request(array(), array('g-recaptcha-response' => array('x'))); $this->assertFalse(captcha_verify_response()); $this->assertSame(array(), FakeRecaptcha::$requests); } public function testSolvedCaptchaPasses(): void { 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 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://recaptchaenterprise.googleapis.com/v1/projects/test-project/assessments', $request['url']); $this->assertSame('POST', $request['http']['method']); $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('{"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"', 'null', 'true', '{"success":true}', '{}', '{"tokenProperties":[]}', '{"tokenProperties":true}', '{"tokenProperties":"valid"}', '{"tokenProperties":{}}') as $reply) { FakeRecaptcha::install($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_check()); $this->assertArrayNotHasKey('captcha_ok', $_SESSION); $this->assertStringContainsString('xxx: reCAPTCHA assessment request failed', $this->loggedErrors()); } 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($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 { $this->assertFalse(captcha_passed()); $_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS + 5; $this->assertTrue(captcha_passed()); $_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1; $this->assertFalse(captcha_passed()); } public function testCheckRemembersASolvedCaptcha(): void { FakeRecaptcha::install(); $this->request(array(), array('g-recaptcha-response' => 'token')); $this->assertTrue(captcha_check()); $this->assertEqualsWithDelta(time(), $_SESSION['captcha_ok'], 2); // The next check needs no new answer from Google FakeRecaptcha::install(FALSE); $this->request(); $this->assertTrue(captcha_check()); $this->assertSame(array(), FakeRecaptcha::$requests); } public function testCheckFailsWithoutAPassOrAnAnswer(): void { FakeRecaptcha::install(); $this->assertFalse(captcha_check()); $this->assertArrayNotHasKey('captcha_ok', $_SESSION); } public function testUsedEndsThePass(): void { $_SESSION['captcha_ok'] = time(); captcha_used(); $this->assertFalse(captcha_passed()); } }