diff --git a/config/forms.php b/config/forms.php index 8c9cc123566..1cb0c139274 100644 --- a/config/forms.php +++ b/config/forms.php @@ -86,6 +86,30 @@ 'delete_spam_submissions_after' => 30, + /* + |-------------------------------------------------------------------------- + | Captcha + |-------------------------------------------------------------------------- + | + | A captcha can be added to any form from its settings. Supported drivers + | are "turnstile", "hcaptcha", "recaptcha" (v2 checkbox), "recaptcha_v3" + | and "null". Captchas stay off until both of the keys have been set. + | + | The score and action only apply to reCAPTCHA v3. Visits scoring under + | the score are refused. The timeout is in seconds, and a provider that + | can't be reached in time counts as a failed check. + | + */ + + 'captcha' => [ + 'driver' => env('STATAMIC_CAPTCHA_DRIVER', 'turnstile'), + 'site_key' => env('STATAMIC_CAPTCHA_SITE_KEY'), + 'secret_key' => env('STATAMIC_CAPTCHA_SECRET_KEY'), + 'score' => 0.5, + 'action' => 'submit', + 'timeout' => 5, + ], + /* |-------------------------------------------------------------------------- | Exporters diff --git a/lang/en/messages.php b/lang/en/messages.php index c7ac0ad37a8..31a307c5ab1 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -130,6 +130,9 @@ 'forgot_password_enter_email' => 'Enter your email address to receive a password reset link.', 'forgot_password_sent' => 'If an account with that email address exists, a password reset link has been sent.', 'form_configure_blueprint_instructions' => 'Choose from existing blueprints or create a new one.', + 'form_configure_captcha_behavior_instructions' => 'What happens to submissions that fail the captcha. They can be refused with an error, or stored and marked as spam for review.', + 'form_configure_captcha_instructions' => 'Add a captcha to this form, using the driver set up in `config/statamic/forms.php`.', + 'form_configure_captcha_not_configured_instructions' => 'Set a captcha driver and its site and secret keys in `config/statamic/forms.php` to add a captcha to this form.', 'form_configure_email_attachments_instructions' => 'Attach files uploaded with the submission.', 'form_configure_email_body_instructions' => 'Type @ to include submitted values. Leave blank to list all submitted fields instead.', 'form_configure_email_from_instructions' => 'Leave blank to fall back to the site default.', diff --git a/lang/en/validation.php b/lang/en/validation.php index 4f6e6c8deee..8a189d7b5bd 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -182,6 +182,7 @@ 'handle' => 'Must contain only lowercase letters and numbers with underscores as separators.', 'handle_starts_with_number' => 'Cannot start with a number.', 'slug' => 'Must contain only letters and numbers with dashes or underscores as separators.', + 'captcha' => 'The captcha could not be verified. Please try again.', 'code_fieldtype_rulers' => 'This is invalid.', 'composer_package' => 'Must be a valid composer package name (eg. hasselhoff/kung-fury).', 'date_fieldtype_date_required' => 'Date is required.', diff --git a/src/Contracts/Forms/Captcha.php b/src/Contracts/Forms/Captcha.php new file mode 100644 index 00000000000..48235b257cb --- /dev/null +++ b/src/Contracts/Forms/Captcha.php @@ -0,0 +1,26 @@ +app['config']['statamic.forms.captcha.driver'] ?: 'null'; + } + + protected function getConfig($name) + { + return array_merge($this->app['config']['statamic.forms.captcha'] ?? [], ['driver' => $name]); + } + + protected function invalidImplementationMessage($name) + { + return "Captcha driver [{$name}] is not defined."; + } + + protected function invalidDriverMessage($driver, $name) + { + return "Captcha driver [{$driver}] is not defined."; + } + + /** + * Whether a captcha has been set up. The built in drivers need both keys, + * so they stay off until both are set. Custom drivers decide for themselves + * what they need, so they're on as soon as they're the configured driver. + */ + public function enabled(): bool + { + $driver = $this->getDefaultDriver(); + + if ($driver === 'null') { + return false; + } + + if (isset($this->customCreators[$driver])) { + return true; + } + + $config = $this->getConfig($driver); + + return filled($config['site_key'] ?? null) && filled($config['secret_key'] ?? null); + } + + /** + * The captcha a form should use, or null when it shouldn't use one. + */ + public function forForm(Form $form): ?Captcha + { + if (! $form->get('captcha') || ! $this->enabled()) { + return null; + } + + return $this->driver(); + } + + public function createNullDriver() + { + return new NullCaptcha; + } + + public function createTurnstileDriver(array $config) + { + return new Turnstile($config); + } + + public function createHcaptchaDriver(array $config) + { + return new Hcaptcha($config); + } + + public function createRecaptchaDriver(array $config) + { + return new Recaptcha($config); + } + + public function createRecaptchaV3Driver(array $config) + { + return new RecaptchaV3($config); + } +} diff --git a/src/Forms/Captcha/Hcaptcha.php b/src/Forms/Captcha/Hcaptcha.php new file mode 100644 index 00000000000..28f4d4e80fd --- /dev/null +++ b/src/Forms/Captcha/Hcaptcha.php @@ -0,0 +1,27 @@ + $this->siteKey()]; + } + + public function widget(): string + { + return '
' + .''; + } +} diff --git a/src/Forms/Captcha/NullCaptcha.php b/src/Forms/Captcha/NullCaptcha.php new file mode 100644 index 00000000000..b1dd13b9e94 --- /dev/null +++ b/src/Forms/Captcha/NullCaptcha.php @@ -0,0 +1,23 @@ +attr($this->siteKey()).'">' + .''; + } +} diff --git a/src/Forms/Captcha/RecaptchaV3.php b/src/Forms/Captcha/RecaptchaV3.php new file mode 100644 index 00000000000..d2ea3bb975c --- /dev/null +++ b/src/Forms/Captcha/RecaptchaV3.php @@ -0,0 +1,46 @@ +attr($this->siteKey()); + + // Asks Google for a token as the form is submitted, then submits it again. The ready() + // stub is Google's documented way to queue the call until the async script has loaded. + return '' + .'' + .''; + } + + public function action(): string + { + $action = $this->config['action'] ?? null; + + // Google allows letters, digits, slashes and underscores. + return is_string($action) && preg_match('#^[A-Za-z0-9/_]+$#', $action) ? $action : 'submit'; + } + + public function score(): float + { + $score = $this->config['score'] ?? null; + + return is_numeric($score) ? (float) $score : 0.5; + } + + protected function passed(array $result): bool + { + return parent::passed($result) + && ($result['action'] ?? null) === $this->action() + && is_numeric($result['score'] ?? null) + && (float) $result['score'] >= $this->score(); + } +} diff --git a/src/Forms/Captcha/SiteverifyCaptcha.php b/src/Forms/Captcha/SiteverifyCaptcha.php new file mode 100644 index 00000000000..e4c1ef09f3f --- /dev/null +++ b/src/Forms/Captcha/SiteverifyCaptcha.php @@ -0,0 +1,90 @@ + static::MAX_TOKEN_LENGTH) { + return false; + } + + try { + $response = Http::asForm() + ->timeout($this->timeout()) + ->connectTimeout($this->timeout()) + ->post($this->endpoint(), $this->payload($token, $ip)); + } catch (Throwable $e) { + return false; + } + + if (! $response->successful() || ! is_array($result = $response->json())) { + return false; + } + + return $this->passed($result); + } + + protected function payload(string $token, ?string $ip): array + { + return array_filter([ + 'secret' => $this->secretKey(), + 'response' => $token, + 'remoteip' => $ip, + ], fn ($value) => $value !== null); + } + + protected function passed(array $result): bool + { + return ($result['success'] ?? false) === true; + } + + protected function siteKey(): string + { + return (string) ($this->config['site_key'] ?? ''); + } + + protected function secretKey(): string + { + return (string) ($this->config['secret_key'] ?? ''); + } + + protected function timeout(): int|float + { + $timeout = $this->config['timeout'] ?? 5; + + return is_numeric($timeout) && $timeout > 0 ? $timeout + 0 : 5; + } + + protected function attr(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + protected function js(string $value): string + { + return json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + } +} diff --git a/src/Forms/Captcha/Turnstile.php b/src/Forms/Captcha/Turnstile.php new file mode 100644 index 00000000000..d1d08d7e15e --- /dev/null +++ b/src/Forms/Captcha/Turnstile.php @@ -0,0 +1,22 @@ +attr($this->siteKey()).'">' + .''; + } +} diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php index 96f343e57aa..e9683f7d9de 100644 --- a/src/Forms/SubmitForm.php +++ b/src/Forms/SubmitForm.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Traits\Localizable; use Illuminate\Validation\ValidationException; +use Statamic\Contracts\Forms\Captcha as CaptchaContract; use Statamic\Contracts\Forms\Form; use Statamic\Contracts\Forms\Submission; use Statamic\Events\FormSubmitted; @@ -13,11 +14,14 @@ use Statamic\Exceptions\SilentFormFailureException; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; +use Statamic\Facades\Captcha; use Statamic\Facades\Site; use Statamic\Forms\Logic\PageLogic; use Statamic\Rules\AllowedFile; use Statamic\Support\Arr; +use function Statamic\trans as __; + class SubmitForm { use Localizable; @@ -91,6 +95,10 @@ public function submit(array $data, array $files = []): SubmissionResult $this->rejectHoneypotSubmission(); } + if ($captcha = Captcha::forForm($this->form)) { + $this->verifyCaptcha($captcha, $data); + } + $accepted = FormSubmitted::dispatch($this->submission) !== false; if ($this->submission->isSpam()) { @@ -195,6 +203,25 @@ private function rejectHoneypotSubmission(): void $this->rejectSpamSubmission(); } + private function verifyCaptcha(CaptchaContract $captcha, array $data): void + { + $token = $data[$captcha->field()] ?? null; + + if ($captcha->verify(is_string($token) ? $token : null, request()->ip())) { + return; + } + + if ($this->form->store() && $this->form->get('captcha_behavior', 'reject') === 'mark_as_spam') { + $this->submission->markAsSpam(); + + $this->rejectSpamSubmission(); + } + + throw ValidationException::withMessages([ + 'captcha' => $this->withLocale($this->site()?->lang(), fn () => __('statamic::validation.captcha')), + ]); + } + private function rejectSpamSubmission(): void { if ($this->form->store()) { diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php index 1e73d40af29..e6a940f3e70 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -11,6 +11,7 @@ use Statamic\Facades\Antlers; use Statamic\Facades\Blink; use Statamic\Facades\Blueprint; +use Statamic\Facades\Captcha; use Statamic\Facades\Form; use Statamic\Fields\Tab; use Statamic\Forms\JsDrivers\AbstractJsDriver; @@ -92,6 +93,8 @@ public function create() $data['honeypot'] = $form->honeypot(); + $data['captcha'] = $this->captchaWidget(); + $data['button_label'] = Arr::get($data['page'], 'button_label'); if (! $this->isFirstPage()) { @@ -156,7 +159,13 @@ public function create() $html .= $this->formMetaFields($params); - $html .= $this->parse($data); + $html .= $contents = $this->parse($data); + + // Templates can place the captcha themselves with {{ captcha }} or {{ form:captcha }}. + // When they haven't, it goes at the end of the form so it can't be forgotten. + if ($data['captcha'] && ! str_contains($contents, 'data-captcha')) { + $html .= $data['captcha']; + } $html = $this->runHooks('before-close', ['html' => $html, 'data' => $data])['html']; $html .= $this->formClose(); @@ -213,6 +222,24 @@ public function fields() return Antlers::parse('{{ fields '.$params.' }}'.$this->content.'{{ /fields }}', $context, ! GlobalRuntimeState::$isEvaluatingUserData); } + /** + * Maps to {{ form:captcha }}. + * + * Outputs the captcha widget, for templates that want to place it themselves. + * + * @return string + */ + public function captcha() + { + if ($this->context->has('captcha') && ! $this->params->hasAny(static::HANDLE_PARAM)) { + return $this->context->get('captcha'); + } + + $this->getForm(); + + return $this->captchaWidget(); + } + /** * Maps to {{ form:errors }}. * @@ -538,6 +565,24 @@ protected function formHandle() return $form; } + /** + * The captcha only goes on the last page, since that's where it's checked. + */ + private function captchaWidget(): string + { + if (! $captcha = Captcha::forForm($this->form())) { + return ''; + } + + $pages = $this->form()->formFields()->pages(); + + if (Arr::get($pages->last(), 'id') !== Arr::get($this->currentPage(), 'id')) { + return ''; + } + + return $captcha->widget(); + } + private function isFirstPage(): bool { $pages = $this->form()->formFields()->pages(); diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php index 4bd407d4863..b35536b78bf 100644 --- a/src/Http/Controllers/CP/Forms/FormsController.php +++ b/src/Http/Controllers/CP/Forms/FormsController.php @@ -9,6 +9,7 @@ use Statamic\Contracts\Forms\Form as FormContract; use Statamic\CP\Column; use Statamic\Facades\Blueprint; +use Statamic\Facades\Captcha; use Statamic\Facades\Form; use Statamic\Facades\FormConnector; use Statamic\Facades\Site; @@ -223,6 +224,27 @@ protected function editFormBlueprint($form) ], 'instructions' => __('statamic::messages.form_configure_honeypot_behavior_instructions'), ], + 'captcha' => [ + 'display' => __('Captcha'), + 'type' => 'toggle', + 'instructions' => Captcha::enabled() + ? __('statamic::messages.form_configure_captcha_instructions') + : __('statamic::messages.form_configure_captcha_not_configured_instructions'), + ], + 'captcha_behavior' => [ + 'display' => __('Captcha Behavior'), + 'type' => 'button_group', + 'default' => 'reject', + 'options' => [ + 'reject' => __('Show an Error'), + 'mark_as_spam' => __('Save as Spam'), + ], + 'if' => [ + 'captcha' => 'equals true', + 'store' => 'equals true', + ], + 'instructions' => __('statamic::messages.form_configure_captcha_behavior_instructions'), + ], ], ], 'submissions' => [ diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index f53ca96dd32..5f2082fd8dd 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -176,6 +176,10 @@ public function register() $this->app->singleton(IconManager::class); + $this->app->singleton(\Statamic\Forms\Captcha\CaptchaManager::class, function ($app) { + return new \Statamic\Forms\Captcha\CaptchaManager($app); + }); + collect([ \Statamic\Contracts\Entries\EntryRepository::class => \Statamic\Stache\Repositories\EntryRepository::class, \Statamic\Contracts\Taxonomies\TermRepository::class => \Statamic\Stache\Repositories\TermRepository::class, diff --git a/tests/DeletesDirectories.php b/tests/DeletesDirectories.php index c6c976a7a16..94420ad0ab8 100644 --- a/tests/DeletesDirectories.php +++ b/tests/DeletesDirectories.php @@ -24,7 +24,7 @@ protected function deleteDirectory(string $directory): void // each other. unlink() removes files and file symlinks, rmdir() removes // empty directories, directory symlinks and junctions without touching // what they point at, and anything surviving both has contents in it. - if (@unlink($path) || @rmdir($path)) { + if ($this->removePath($path)) { continue; } @@ -33,4 +33,17 @@ protected function deleteDirectory(string $directory): void @rmdir($directory); } + + // The failed attempt is expected, so its warning is swallowed by a handler rather than + // `@`. Pest reports warnings that `@` silenced, which would flood every run. + protected function removePath(string $path): bool + { + set_error_handler(fn () => true); + + try { + return unlink($path) || rmdir($path); + } finally { + restore_error_handler(); + } + } } diff --git a/tests/Feature/Forms/UpdateFormTest.php b/tests/Feature/Forms/UpdateFormTest.php index 57804ed2556..9f671a921a8 100644 --- a/tests/Feature/Forms/UpdateFormTest.php +++ b/tests/Feature/Forms/UpdateFormTest.php @@ -81,6 +81,25 @@ public function it_updates_restrictions() $this->assertTrue($updated->get('require_login')); } + #[Test] + public function it_updates_the_captcha_settings() + { + $form = tap(Form::make('test'))->save(); + + $this + ->actingAs($this->userWithPermission()) + ->update($form, [ + 'store' => true, + 'captcha' => true, + 'captcha_behavior' => 'mark_as_spam', + ]) + ->assertOk(); + + $updated = Form::all()->first(); + $this->assertTrue($updated->get('captcha')); + $this->assertEquals('mark_as_spam', $updated->get('captcha_behavior')); + } + #[Test] public function it_updates_data() { diff --git a/tests/Forms/Captcha/CaptchaDriversTest.php b/tests/Forms/Captcha/CaptchaDriversTest.php new file mode 100644 index 00000000000..b27e6132df9 --- /dev/null +++ b/tests/Forms/Captcha/CaptchaDriversTest.php @@ -0,0 +1,252 @@ + 'site-key', 'secret_key' => 'secret-key']; + + public static function driverProvider() + { + return [ + 'turnstile' => [Turnstile::class, 'https://challenges.cloudflare.com/turnstile/v0/siteverify', 'cf-turnstile-response'], + 'hcaptcha' => [Hcaptcha::class, 'https://api.hcaptcha.com/siteverify', 'h-captcha-response'], + 'recaptcha' => [Recaptcha::class, 'https://www.google.com/recaptcha/api/siteverify', 'g-recaptcha-response'], + ]; + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_verifies_a_token_with_the_provider($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => true])]); + + $captcha = new $class(self::CONFIG); + + $this->assertTrue($captcha->verify('the-token', '127.0.0.1')); + $this->assertEquals($field, $captcha->field()); + + Http::assertSent(function (Request $request) use ($endpoint) { + return $request->url() === $endpoint + && $request->method() === 'POST' + && $request->isForm() + && $request['secret'] === 'secret-key' + && $request['response'] === 'the-token' + && $request['remoteip'] === '127.0.0.1'; + }); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_when_the_provider_says_so($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']])]); + + $this->assertFalse((new $class(self::CONFIG))->verify('the-token')); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_when_success_isnt_exactly_true($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => 'true'])]); + + $this->assertFalse((new $class(self::CONFIG))->verify('the-token')); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_when_the_provider_errors($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => true], 500)]); + + $this->assertFalse((new $class(self::CONFIG))->verify('the-token')); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_when_the_provider_returns_something_other_than_json($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response('nope')]); + + $this->assertFalse((new $class(self::CONFIG))->verify('the-token')); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_when_the_provider_cant_be_reached($class, $endpoint, $field) + { + Http::fake([$endpoint => fn () => throw new ConnectionException('Timed out')]); + + $this->assertFalse((new $class(self::CONFIG))->verify('the-token')); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_without_a_request_when_there_is_no_token($class, $endpoint, $field) + { + Http::fake(); + + $captcha = new $class(self::CONFIG); + + $this->assertFalse($captcha->verify(null)); + $this->assertFalse($captcha->verify('')); + + Http::assertNothingSent(); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_fails_without_a_request_when_the_token_is_too_long($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => true])]); + + $captcha = new $class(self::CONFIG); + + $this->assertFalse($captcha->verify(str_repeat('a', 8193))); + Http::assertNothingSent(); + + $this->assertTrue($captcha->verify(str_repeat('a', 8192))); + } + + #[Test] + #[DataProvider('driverProvider')] + public function it_leaves_out_the_ip_when_there_isnt_one($class, $endpoint, $field) + { + Http::fake([$endpoint => Http::response(['success' => true])]); + + (new $class(self::CONFIG))->verify('the-token'); + + Http::assertSent(fn (Request $request) => ! array_key_exists('remoteip', $request->data())); + } + + #[Test] + public function hcaptcha_sends_the_site_key() + { + Http::fake(['https://api.hcaptcha.com/siteverify' => Http::response(['success' => true])]); + + (new Hcaptcha(self::CONFIG))->verify('the-token'); + + Http::assertSent(fn (Request $request) => $request['sitekey'] === 'site-key'); + } + + #[Test] + public function it_renders_the_widgets() + { + $this->assertEquals( + '
', + (new Turnstile(self::CONFIG))->widget() + ); + + $this->assertEquals( + '
', + (new Hcaptcha(self::CONFIG))->widget() + ); + + $this->assertEquals( + '
', + (new Recaptcha(self::CONFIG))->widget() + ); + } + + #[Test] + public function it_escapes_the_site_key_in_the_widget() + { + $widget = (new Turnstile(['site_key' => '">']))->widget(); + + $this->assertStringNotContainsString('', $widget); + $this->assertStringContainsString('data-sitekey=""><script>', $widget); + } + + #[Test] + public function recaptcha_v3_passes_with_a_good_enough_score_for_the_right_action() + { + Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response([ + 'success' => true, 'score' => 0.7, 'action' => 'submit', + ])]); + + $this->assertTrue((new RecaptchaV3(self::CONFIG))->verify('the-token')); + } + + #[Test] + public function recaptcha_v3_fails_with_a_low_score() + { + Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response([ + 'success' => true, 'score' => 0.3, 'action' => 'submit', + ])]); + + $this->assertFalse((new RecaptchaV3(self::CONFIG))->verify('the-token')); + } + + #[Test] + public function recaptcha_v3_uses_the_configured_score() + { + Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response([ + 'success' => true, 'score' => 0.3, 'action' => 'submit', + ])]); + + $this->assertTrue((new RecaptchaV3([...self::CONFIG, 'score' => 0.2]))->verify('the-token')); + $this->assertFalse((new RecaptchaV3([...self::CONFIG, 'score' => 0.9]))->verify('the-token')); + } + + #[Test] + public function recaptcha_v3_fails_without_a_score() + { + Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response([ + 'success' => true, 'action' => 'submit', + ])]); + + $this->assertFalse((new RecaptchaV3(self::CONFIG))->verify('the-token')); + } + + #[Test] + public function recaptcha_v3_fails_for_a_token_made_for_another_action() + { + Http::fake(['https://www.google.com/recaptcha/api/siteverify' => Http::response([ + 'success' => true, 'score' => 0.9, 'action' => 'login', + ])]); + + $this->assertFalse((new RecaptchaV3(self::CONFIG))->verify('the-token')); + $this->assertTrue((new RecaptchaV3([...self::CONFIG, 'action' => 'login']))->verify('the-token')); + } + + #[Test] + public function recaptcha_v3_falls_back_to_the_default_action_when_the_configured_one_is_invalid() + { + $captcha = new RecaptchaV3([...self::CONFIG, 'action' => 'not allowed!']); + + $this->assertEquals('submit', $captcha->action()); + } + + #[Test] + public function recaptcha_v3_renders_a_script_safe_widget() + { + $widget = (new RecaptchaV3(['site_key' => ""]))->widget(); + + $this->assertStringContainsString('', $widget); + $this->assertStringNotContainsString("", $widget); + // Only the two script tags the widget opens are closed, so the key can't break out of them. + $this->assertEquals(2, substr_count($widget, '')); + $this->assertStringContainsString('{action:"submit"}', $widget); + } + + #[Test] + public function recaptcha_v3_waits_for_the_script_instead_of_submitting_without_a_token() + { + $widget = (new RecaptchaV3(self::CONFIG))->widget(); + + $this->assertStringContainsString('if(input.value)return;event.preventDefault();grecaptcha.ready(', $widget); + $this->assertStringContainsString('grecaptcha.ready=grecaptcha.ready||function(cb)', $widget); + } +} diff --git a/tests/Forms/Captcha/CaptchaManagerTest.php b/tests/Forms/Captcha/CaptchaManagerTest.php new file mode 100644 index 00000000000..66de3cffc6f --- /dev/null +++ b/tests/Forms/Captcha/CaptchaManagerTest.php @@ -0,0 +1,150 @@ + $driver]); + + $this->assertInstanceOf($class, Captcha::driver()); + } + + public static function driverProvider() + { + return [ + 'turnstile' => ['turnstile', Turnstile::class], + 'hcaptcha' => ['hcaptcha', Hcaptcha::class], + 'recaptcha' => ['recaptcha', Recaptcha::class], + 'recaptcha_v3' => ['recaptcha_v3', RecaptchaV3::class], + 'null' => ['null', NullCaptcha::class], + ]; + } + + #[Test] + public function it_uses_the_null_driver_when_no_driver_is_configured() + { + config(['statamic.forms.captcha.driver' => null]); + + $this->assertInstanceOf(NullCaptcha::class, Captcha::driver()); + $this->assertFalse(Captcha::enabled()); + } + + #[Test] + public function it_throws_an_exception_for_an_unknown_driver() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Captcha driver [nope] is not defined.'); + + config(['statamic.forms.captcha.driver' => 'nope']); + + Captcha::driver(); + } + + #[Test] + public function it_passes_the_config_to_the_driver() + { + config(['statamic.forms.captcha' => [ + 'driver' => 'turnstile', + 'site_key' => 'the-site-key', + 'secret_key' => 'the-secret-key', + ]]); + + $this->assertStringContainsString('data-sitekey="the-site-key"', Captcha::driver()->widget()); + } + + #[Test] + public function it_is_only_enabled_once_both_keys_are_set() + { + config(['statamic.forms.captcha' => ['driver' => 'turnstile', 'site_key' => null, 'secret_key' => null]]); + $this->assertFalse(Captcha::enabled()); + + config(['statamic.forms.captcha.site_key' => 'site']); + $this->assertFalse(Captcha::enabled()); + + config(['statamic.forms.captcha' => ['driver' => 'turnstile', 'site_key' => null, 'secret_key' => 'secret']]); + $this->assertFalse(Captcha::enabled()); + + config(['statamic.forms.captcha.site_key' => 'site']); + $this->assertTrue(Captcha::enabled()); + } + + #[Test] + public function it_is_not_enabled_with_the_null_driver_even_with_keys() + { + config(['statamic.forms.captcha' => ['driver' => 'null', 'site_key' => 'site', 'secret_key' => 'secret']]); + + $this->assertFalse(Captcha::enabled()); + } + + #[Test] + public function custom_drivers_can_be_added() + { + $custom = new class implements CaptchaContract + { + public array $config = []; + + public function widget(): string + { + return '
custom
'; + } + + public function verify(?string $token, ?string $ip = null): bool + { + return $token === 'ok'; + } + + public function field(): string + { + return 'custom-token'; + } + }; + + Captcha::extend('custom', function ($app, $config) use ($custom) { + $custom->config = $config; + + return $custom; + }); + + config(['statamic.forms.captcha' => ['driver' => 'custom', 'site_key' => null, 'secret_key' => null, 'foo' => 'bar']]); + + $this->assertTrue(Captcha::enabled()); + $this->assertSame($custom, Captcha::driver()); + $this->assertEquals('bar', $custom->config['foo']); + $this->assertEquals('custom', $custom->config['driver']); + } + + #[Test] + public function it_gets_the_captcha_for_a_form_that_uses_one() + { + config(['statamic.forms.captcha' => ['driver' => 'turnstile', 'site_key' => 'site', 'secret_key' => 'secret']]); + + $this->assertNull(Captcha::forForm(Form::make('contact'))); + $this->assertNull(Captcha::forForm(Form::make('contact')->data(['captcha' => false]))); + $this->assertInstanceOf(Turnstile::class, Captcha::forForm(Form::make('contact')->data(['captcha' => true]))); + } + + #[Test] + public function it_doesnt_get_a_captcha_for_a_form_when_captchas_arent_set_up() + { + config(['statamic.forms.captcha' => ['driver' => 'turnstile', 'site_key' => null, 'secret_key' => null]]); + + $this->assertNull(Captcha::forForm(Form::make('contact')->data(['captcha' => true]))); + } +} diff --git a/tests/Forms/Captcha/SubmitFormCaptchaTest.php b/tests/Forms/Captcha/SubmitFormCaptchaTest.php new file mode 100644 index 00000000000..3c855f5fe46 --- /dev/null +++ b/tests/Forms/Captcha/SubmitFormCaptchaTest.php @@ -0,0 +1,258 @@ +andReturnFalse()->byDefault(); + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturnTrue()->byDefault(); + + config(['statamic.forms.captcha' => [ + 'driver' => 'turnstile', + 'site_key' => 'site-key', + 'secret_key' => 'secret-key', + ]]); + + $this->form = tap(Form::make('contact')->honeypot('winnie')->data(['captcha' => true])->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [['handle' => 'email', 'field' => ['type' => 'email', 'validate' => 'required']]]], + ], + ], + ], + ]))->save(); + } + + public function tearDown(): void + { + Form::all()->each(fn ($form) => $form->submissions()->each->delete()); + + parent::tearDown(); + } + + private function action($form = null): SubmitForm + { + $form ??= $this->form; + + return app(SubmitForm::class)->form($form)->page($form->formFields()->pages()->first()['id']); + } + + private function passCaptcha(): void + { + Http::fake([self::SITEVERIFY => Http::response(['success' => true])]); + } + + private function failCaptcha(): void + { + Http::fake([self::SITEVERIFY => Http::response(['success' => false])]); + } + + private function assertCaptchaError(callable $callback): void + { + try { + $callback(); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertEquals(['captcha' => ['The captcha could not be verified. Please try again.']], $e->errors()); + } + } + + #[Test] + public function it_submits_when_the_captcha_passes() + { + Event::fake([FormSubmitted::class]); + $this->passCaptcha(); + + $result = $this->action()->submit(['email' => 'test@example.com', 'cf-turnstile-response' => 'the-token']); + + $this->assertTrue($result->isFinalized()); + $this->assertNull($result->submission->get('cf-turnstile-response')); + Event::assertDispatched(FormSubmitted::class); + + Http::assertSent(fn (Request $request) => $request['response'] === 'the-token' && $request['remoteip'] === '127.0.0.1'); + } + + #[Test] + public function it_rejects_the_submission_when_the_captcha_fails() + { + Event::fake([FormSubmitted::class]); + $this->failCaptcha(); + + $this->assertCaptchaError(fn () => $this->action()->submit(['email' => 'test@example.com', 'cf-turnstile-response' => 'bad'])); + + $this->assertCount(0, $this->form->submissions()); + Event::assertNotDispatched(FormSubmitted::class); + } + + #[Test] + public function it_rejects_the_submission_when_the_token_is_missing_without_calling_the_provider() + { + Http::fake(); + + $this->assertCaptchaError(fn () => $this->action()->submit(['email' => 'test@example.com'])); + + Http::assertNothingSent(); + } + + #[Test] + public function it_ignores_a_token_that_isnt_a_string() + { + Http::fake(); + + $this->assertCaptchaError(fn () => $this->action()->submit(['email' => 'test@example.com', 'cf-turnstile-response' => ['nope']])); + + Http::assertNothingSent(); + } + + #[Test] + public function it_saves_the_submission_as_spam_when_the_captcha_fails_and_configured_to_do_so() + { + Bus::fake(); + Event::fake([FormSubmitted::class, SubmissionFinalized::class]); + $this->failCaptcha(); + + $this->form->merge(['captcha_behavior' => 'mark_as_spam'])->save(); + + try { + $this->action()->submit(['email' => 'test@example.com', 'cf-turnstile-response' => 'bad']); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + // Expected + } + + $submission = $this->form->submissions()->first(); + + $this->assertNotNull($submission); + $this->assertTrue($submission->isSpam()); + $this->assertEquals('test@example.com', $submission->get('email')); + + Event::assertNotDispatched(FormSubmitted::class); + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + } + + #[Test] + public function it_rejects_instead_of_saving_as_spam_when_the_form_does_not_store_submissions() + { + $this->failCaptcha(); + + $this->form->store(false)->merge(['captcha_behavior' => 'mark_as_spam'])->save(); + + $this->assertCaptchaError(fn () => $this->action()->submit(['email' => 'test@example.com', 'cf-turnstile-response' => 'bad'])); + + $this->assertCount(0, $this->form->submissions()); + } + + #[Test] + public function the_honeypot_is_checked_before_the_captcha() + { + Http::fake(); + + $this->expectException(SilentFormFailureException::class); + + try { + $this->action()->submit(['email' => 'test@example.com', 'winnie' => 'the pooh']); + } finally { + Http::assertNothingSent(); + } + } + + #[Test] + public function fields_are_validated_before_the_captcha() + { + Http::fake(); + + try { + $this->action()->submit(['cf-turnstile-response' => 'the-token']); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('email', $e->errors()); + } + + Http::assertNothingSent(); + } + + #[Test] + public function it_doesnt_check_the_captcha_when_the_form_doesnt_use_one() + { + Http::fake(); + + $this->form->merge(['captcha' => false])->save(); + + $this->assertTrue($this->action()->submit(['email' => 'test@example.com'])->isFinalized()); + + Http::assertNothingSent(); + } + + #[Test] + public function it_doesnt_check_the_captcha_when_captchas_arent_set_up() + { + Http::fake(); + + config(['statamic.forms.captcha.secret_key' => null]); + + $this->assertTrue($this->action()->submit(['email' => 'test@example.com'])->isFinalized()); + + Http::assertNothingSent(); + } + + #[Test] + public function it_only_checks_the_captcha_on_the_final_page() + { + Http::fake([self::SITEVERIFY => Http::response(['success' => true])]); + + $form = tap(Form::make('signup')->data(['captcha' => true])->formFields([ + 'pages' => [ + ['id' => 'one', 'sections' => [['fields' => [['handle' => 'name', 'field' => ['type' => 'short_answer']]]]]], + ['id' => 'two', 'sections' => [['fields' => [['handle' => 'email', 'field' => ['type' => 'email']]]]]], + ], + ]))->save(); + + $first = $this->action($form)->submit(['name' => 'Bob']); + + $this->assertFalse($first->isFinalized()); + Http::assertNothingSent(); + + $this->assertCaptchaError(fn () => app(SubmitForm::class)->form($form)->page('two')->resume($first->submission)->submit(['email' => 'bob@example.com'])); + + // The partial survives a failed captcha, so the visitor can try again. + $this->assertTrue($form->submission($first->submission->id())->isPartial()); + + $result = app(SubmitForm::class)->form($form)->page('two')->resume($first->submission) + ->submit(['email' => 'bob@example.com', 'cf-turnstile-response' => 'the-token']); + + $this->assertTrue($result->isFinalized()); + Http::assertSentCount(1); + } +} diff --git a/tests/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php index 98444e89dc1..8567c80f1ff 100644 --- a/tests/RestoresTestbenchSkeleton.php +++ b/tests/RestoresTestbenchSkeleton.php @@ -78,7 +78,7 @@ private function purgeTestbenchSkeleton(): void ->flatMap(fn ($path) => str_contains($path, '*') ? $files->glob($path) : [$path]); foreach ($expand($purge['files']) as $file) { - $files->delete($file); + $this->removePath($file); } foreach ($expand($purge['directories']) as $directory) { @@ -104,7 +104,7 @@ protected function restoreTestbenchSkeleton(): void // directory or a link, because a junction gives contradictory answers. unlink() // takes files and file links, rmdir() takes empty directories, directory links // and junctions without following them. - @unlink($absolute) || @rmdir($absolute); + $this->removePath($absolute); } } diff --git a/tests/Tags/Form/FormCaptchaTest.php b/tests/Tags/Form/FormCaptchaTest.php new file mode 100644 index 00000000000..a6fb3308c9e --- /dev/null +++ b/tests/Tags/Form/FormCaptchaTest.php @@ -0,0 +1,128 @@ +'; + + public function setUp(): void + { + parent::setUp(); + + config(['statamic.forms.captcha' => [ + 'driver' => 'turnstile', + 'site_key' => 'site-key', + 'secret_key' => 'secret-key', + ]]); + + Form::find('contact')->data(['captcha' => true]); + } + + #[Test] + public function it_adds_the_widget_to_the_end_of_the_form() + { + $output = $this->tag('{{ form:contact }}{{ /form:contact }}'); + + $this->assertStringContainsString(''.self::WIDGET.'', $output); + } + + #[Test] + public function it_doesnt_add_the_widget_when_the_template_places_it() + { + $output = $this->tag('{{ form:contact }}{{ captcha }}{{ /form:contact }}'); + + $this->assertEquals(1, substr_count($output, 'data-captcha')); + $this->assertStringContainsString(self::WIDGET.'', $output); + } + + #[Test] + public function it_places_the_widget_with_the_captcha_tag() + { + $output = $this->tag('{{ form:contact }}
{{ form:captcha }}
{{ /form:contact }}'); + + $this->assertEquals(1, substr_count($output, 'data-captcha')); + $this->assertStringContainsString('
'.self::WIDGET.'
', $output); + } + + #[Test] + public function the_captcha_tag_can_be_used_with_a_form_handle() + { + $this->assertEquals(self::WIDGET, $this->tag('{{ form:captcha in="contact" }}')); + } + + #[Test] + public function it_doesnt_render_anything_when_the_form_doesnt_use_a_captcha() + { + Form::find('contact')->data(['captcha' => false]); + + $output = $this->tag('{{ form:contact }}{{ captcha }}{{ form:captcha }}{{ /form:contact }}'); + + $this->assertStringNotContainsString('data-captcha', $output); + } + + #[Test] + public function it_doesnt_render_anything_when_captchas_arent_set_up() + { + config(['statamic.forms.captcha.site_key' => null]); + + $output = $this->tag('{{ form:contact }}{{ /form:contact }}'); + + $this->assertStringNotContainsString('data-captcha', $output); + } + + #[Test] + public function it_only_renders_the_widget_on_the_last_page() + { + $this->createMultiPageForm(); + Form::find('survey')->data(['captcha' => true]); + + $this->assertStringNotContainsString('data-captcha', $this->tag('{{ form:survey }}{{ /form:survey }}')); + + request()->query->set('page', 'page_two'); + + $this->assertStringContainsString(self::WIDGET.'', $this->tag('{{ form:survey }}{{ /form:survey }}')); + } + + #[Test] + public function it_includes_the_widget_in_blade_component_data() + { + $output = $this->blade('{!! $captcha !!}'); + + $this->assertEquals(1, substr_count($output, 'data-captcha')); + } + + #[Test] + public function it_shows_an_error_when_the_captcha_fails() + { + Http::fake(['https://challenges.cloudflare.com/*' => Http::response(['success' => false])]); + + $this + ->post('/!/forms/contact', ['email' => 'san@holo.com', 'cf-turnstile-response' => 'bad']) + ->assertSessionHasErrors(['captcha'], null, 'form.contact') + ->assertLocation('/'); + + $this->assertCount(0, Form::find('contact')->submissions()); + + $output = $this->tag('{{ form:contact }}{{ error:captcha }}{{ /form:contact }}'); + + $this->assertStringContainsString('The captcha could not be verified. Please try again.', $output); + } + + #[Test] + public function it_submits_when_the_captcha_passes() + { + Http::fake(['https://challenges.cloudflare.com/*' => Http::response(['success' => true])]); + + $this + ->post('/!/forms/contact', ['email' => 'san@holo.com', 'cf-turnstile-response' => 'good']) + ->assertSessionHasNoErrors() + ->assertLocation('/'); + + $this->assertCount(1, Form::find('contact')->submissions()); + } +}