['required', new HCaptcha]`. * * Fails closed: if hCaptcha can't be reached the submission is rejected, since * letting it through would reopen the hole this exists to close (bots making * us send email to arbitrary addresses). */ class HCaptcha implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || $value === '') { $fail(__('storefront.auth.captcha_failed')); return; } try { $response = Http::asForm() ->timeout(5) ->post('https://api.hcaptcha.com/siteverify', [ 'secret' => config('services.hcaptcha.secret'), 'response' => $value, // Rejects tokens solved against someone else's sitekey. 'sitekey' => config('services.hcaptcha.sitekey'), 'remoteip' => request()->ip(), ]); } catch (ConnectionException $e) { Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]); $fail(__('storefront.auth.captcha_failed')); return; } if (! $response->successful() || $response->json('success') !== true) { // A bad/missing secret or sitekey would otherwise look like every // shopper failing the captcha. $configErrors = array_intersect((array) $response->json('error-codes'), [ 'missing-input-secret', 'invalid-input-secret', 'sitekey-secret-mismatch', 'invalid-sitekey', ]); if ($response->failed() || $configErrors) { Log::warning('hCaptcha siteverify error', [ 'status' => $response->status(), 'error-codes' => $response->json('error-codes'), ]); } $fail(__('storefront.auth.captcha_failed')); } } }