Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CloudflareTurnstileVerifier
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 2
210
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 verify
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Contact\Service;
6
7use Psr\Log\LoggerInterface;
8
9use function curl_close;
10use function curl_error;
11use function curl_exec;
12use function curl_getinfo;
13use function curl_init;
14use function curl_setopt_array;
15use function http_build_query;
16use function is_array;
17use function is_string;
18use function json_decode;
19
20/**
21 * cURL-backed Cloudflare Turnstile verifier. POSTs the token to the
22 * siteverify endpoint and reports whether the challenge passed.
23 *
24 * Fails closed: any transport error, malformed response, or unset secret
25 * returns false, so a misconfiguration or a Cloudflare outage blocks the
26 * submission rather than letting spam through. Legitimate users can retry
27 * or use the email fallback shown on the form.
28 */
29final readonly class CloudflareTurnstileVerifier implements TurnstileVerifierInterface
30{
31    private const string SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
32    private const int TIMEOUT_SECONDS = 5;
33
34    public function __construct(
35        private string $secretKey,
36        private LoggerInterface $logger,
37    ) {}
38
39    public function verify(string $token, ?string $remoteIp = null): bool
40    {
41        if ($this->secretKey === '') {
42            $this->logger->error('Turnstile secret_key is not configured; rejecting submission');
43            return false;
44        }
45        if ($token === '') {
46            return false;
47        }
48
49        $fields = ['secret' => $this->secretKey, 'response' => $token];
50        if ($remoteIp !== null && $remoteIp !== '') {
51            $fields['remoteip'] = $remoteIp;
52        }
53
54        $ch = curl_init(self::SITEVERIFY_URL);
55        if ($ch === false) {
56            $this->logger->error('Failed to initialise cURL handle for Turnstile verification');
57            return false;
58        }
59
60        curl_setopt_array($ch, [
61            CURLOPT_POST => true,
62            CURLOPT_POSTFIELDS => http_build_query($fields),
63            CURLOPT_RETURNTRANSFER => true,
64            CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
65            CURLOPT_CONNECTTIMEOUT => self::TIMEOUT_SECONDS,
66            CURLOPT_SSL_VERIFYPEER => true,
67            CURLOPT_SSL_VERIFYHOST => 2,
68        ]);
69
70        $response = curl_exec($ch);
71        $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
72        $error = curl_error($ch);
73        curl_close($ch);
74
75        if (!is_string($response) || $error !== '' || $statusCode < 200 || $statusCode >= 300) {
76            $this->logger->error('Turnstile verification transport error', [
77                'status' => $statusCode,
78                'error' => $error !== '' ? $error : 'unknown',
79            ]);
80            return false;
81        }
82
83        $decoded = json_decode($response, true);
84        if (!is_array($decoded)) {
85            $this->logger->error('Turnstile verification returned a non-JSON response');
86            return false;
87        }
88
89        $success = ($decoded['success'] ?? false) === true;
90        if (!$success) {
91            $this->logger->warning('Turnstile challenge failed', [
92                'error-codes' => $decoded['error-codes'] ?? [],
93            ]);
94        }
95
96        return $success;
97    }
98}