'https://challenges.cloudflare.com/turnstile/v0/siteverify', 'recaptcha' => 'https://www.google.com/recaptcha/api/siteverify', ); /** * The visitor's problem: a bot, a low score, an expired or reused token. Not recorded. */ const REJECTED = 'rejected'; /** * The owner's problem: the provider refused the secret key. Recorded for the owner to see. */ const MISCONFIGURED = 'misconfigured'; /** * The provider's problem: no answer, or one that cannot be read. Recorded for the owner. */ const UNREACHABLE = 'unreachable'; /** * Provider id. * * @var string */ private $provider; /** * Plain secret key. * * @var string */ private $secret; /** * Lowest reCAPTCHA score accepted. * * @var float */ private $threshold; /** * Constructor. * * @param string $provider 'turnstile' or 'recaptcha'. * @param string $secret Plain secret key. * @param float $threshold Lowest reCAPTCHA score accepted; ignored for Turnstile. */ public function __construct( $provider, $secret, $threshold = 0.5 ) { $this->provider = (string) $provider; $this->secret = (string) $secret; $this->threshold = (float) $threshold; } /** * Ask the provider whether a token is good. * * @param string $token The token the visitor's browser got from the provider. * @return true|\WP_Error True, or an error whose code is REJECTED, MISCONFIGURED or UNREACHABLE. */ public function verify( $token ) { if ( ! isset( self::ENDPOINTS[ $this->provider ] ) ) { return new \WP_Error( self::MISCONFIGURED, 'No spam protection provider is chosen.' ); } // Only the secret and the token. The visitor's IP address (remoteip) is optional for both // providers, is a proxy's address behind Cloudflare, and would be more visitor data sent out. $response = wp_remote_post( self::ENDPOINTS[ $this->provider ], array( 'timeout' => self::TIMEOUT, 'body' => array( 'secret' => $this->secret, 'response' => (string) $token, ), ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return new \WP_Error( self::UNREACHABLE, 'The provider did not answer.' ); } $answer = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $answer ) ) { return new \WP_Error( self::UNREACHABLE, 'The provider\'s answer could not be read.' ); } if ( ! isset( $answer['success'] ) || true !== $answer['success'] ) { $codes = isset( $answer['error-codes'] ) && is_array( $answer['error-codes'] ) ? $answer['error-codes'] : array(); return new \WP_Error( self::kind_of( $codes ), 'The provider refused the token.' ); } if ( ! isset( $answer['action'] ) || self::ACTION !== $answer['action'] ) { return new \WP_Error( self::REJECTED, 'The token was made for another action.' ); } if ( 'recaptcha' === $this->provider ) { $score = isset( $answer['score'] ) ? $answer['score'] : null; // is_numeric() refuses true, which (float) would have read as 1.0. if ( ! is_numeric( $score ) || (float) $score < $this->threshold ) { return new \WP_Error( self::REJECTED, 'The score is below the threshold.' ); } } return true; } /** * Whose problem a refusal is, from the provider's error codes. A refused secret outranks * everything, because until it is fixed every visitor is turned away. * * @param array $codes The answer's error-codes. * @return string REJECTED, MISCONFIGURED or UNREACHABLE. */ private static function kind_of( array $codes ) { $codes = array_map( 'strval', array_filter( $codes, 'is_scalar' ) ); if ( array_intersect( $codes, array( 'missing-input-secret', 'invalid-input-secret', 'invalid-parsed-secret' ) ) ) { return self::MISCONFIGURED; } // bad-request is not listed here on purpose: a visitor's malformed token can cause it, and // it must not plant a false provider-outage error for the owner. if ( array_intersect( $codes, array( 'internal-error' ) ) ) { return self::UNREACHABLE; } return self::REJECTED; } }