KOK - MANAGER
Edit File: class-token-verifier.php
<?php /** * Class responsible for verifying tokens returned by Recaptcha. * * @package Gravity_Forms\Gravity_Forms_RECAPTCHA */ namespace Gravity_Forms\Gravity_Forms_RECAPTCHA; use stdClass; /** * Class Token_Verifier * * @since 1.0 * * @package Gravity_Forms\Gravity_Forms_RECAPTCHA */ class Token_Verifier { /** * Error code returned if a token or secret is missing. * * @since 1.0 */ const ERROR_CODE_MISSING_TOKEN_OR_SECRET = 'gravityformsrecaptcha-missing-token-or-secret'; /** * Error code returned if the token cannot be verified. * * @since 1.0 */ const ERROR_CODE_CANNOT_VERIFY_TOKEN = 'gravityforms-cannot-verify-token'; /** * Instance of the add-on class. * * @since 1.0 * @var GF_RECAPTCHA */ private $addon; /** * Class instance. * * @since 1.0 * @var RECAPTCHA_API */ private $api; /** * Minimum score the Recaptcha API can return before a form submission is marked as spam. * * @since 1.0 * @var float */ private $score_threshold; /** * Token generated by the Recaptcha service that requires validation. * * @since 1.0 * @var string */ private $token; /** * Recaptcha application secret used to verify the token. * * @since 1.0 * @var string */ private $secret; /** * Result of the recaptcha request. * * @var stdClass|array */ private $recaptcha_result; /** * The reCAPTCHA action. * * @since 1.4 Previously a dynamic property. * * @var string */ private $action; /** * The connection type. * * @since 1.7.0 * * @var string */ private $connection_type = ''; /** * Token_Verifier constructor. * * @since 1.0 * * @param GF_RECAPTCHA $addon Instance of the GF_RECAPTCHA add-on. * @param RECAPTCHA_API $api Instance of the Recaptcha API. */ public function __construct( GF_RECAPTCHA $addon, RECAPTCHA_API $api ) { $this->addon = $addon; $this->api = $api; } /** * Initializes this object for use. * * @param string $token The reCAPTCHA token. * @param string $action The reCAPTCHA action. * @param string $connection_type The connection type. * * @since 1.0 */ public function init( $token = '', $action = '', $connection_type = null ) { $this->token = $token; $this->action = $action; $this->secret = $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' ); $this->score_threshold = $this->addon->get_plugin_setting( 'score_threshold_v3', 0.5 ); $this->connection_type = $connection_type; } /** * Get the reCAPTCHA result. * * Returns a stdClass if it's already been processed. * * @since 1.0 * * @return stdClass|null */ public function get_recaptcha_result() { return $this->recaptcha_result; } /** * Validate that the reCAPTCHA response data has the required properties and meets expectations. * * @since 1.0 * @since 1.7.0 Added support for enterprise reCAPTCHA. * * @param array $response_data The response data to validate. * @param string $connection_type The connection type. * * @return bool */ private function validate_response_data( $response_data, $connection_type = null ) { if ( $connection_type === 'enterprise' ) { return $this->validate_enterprise_assessment_response( $response_data ); } else { return $this->validate_classic_response( $response_data ); } } /** * Validate the enterprise assessment response. * * @param array $response_data The response data. * * @since 1.7.0 * * @return bool */ private function validate_enterprise_assessment_response( $response_data ) { if ( rgar( $response_data, 'error' ) || rgars( $response_data, 'tokenProperties/valid' ) !== true || ! rgars( $response_data, 'riskAnalysis/score' ) || ! rgars( $response_data, 'tokenProperties/action' ) || ! rgars( $response_data, 'tokenProperties/hostname' ) ) { return false; } return ( rgars( $response_data, 'tokenProperties/valid' ) === true && $this->verify_hostname( rgars( $response_data, 'tokenProperties/hostname' ) ) && $this->verify_action( rgars( $response_data, 'tokenProperties/action' ) ) && $this->verify_score( rgars( $response_data, 'riskAnalysis/score' ) ) ); } /** * Validate the classic reCAPTCHA response. * * @param array $response_data The response data. * * @since 1.0.0 * @since 1.7.0 Moved from the validate_response_data method. * * @return bool */ private function validate_classic_response( $response_data ) { if ( ! empty( $response_data->{'error-codes'} ) || ( property_exists( $response_data, 'success' ) && $response_data->success !== true ) ) { return false; } $validation_properties = array( 'hostname', 'action', 'success', 'score', 'challenge_ts' ); $response_properties = array_filter( $validation_properties, function( $property ) use ( $response_data ) { return property_exists( $response_data, $property ); } ); if ( count( $validation_properties ) !== count( $response_properties ) ) { return false; } return ( $response_data->success && $this->verify_hostname( $response_data->hostname ) && $this->verify_action( $response_data->action ) && $this->verify_score( $response_data->score ) && $this->verify_timestamp( $response_data->challenge_ts ) ); } /** * Verify the submission data. * * @since 1.0 * * @param string $token The Recapatcha token. * * @return bool */ public function verify_submission( $token ) { $data = \GFCache::get( 'recaptcha_' . $token, $found ); if ( $found ) { $this->addon->log_debug( __METHOD__ . '(): Using cached reCAPTCHA result: ' . print_r( $data, true ) ); // @codingStandardsIgnoreLine $this->recaptcha_result = $data; return true; } $this->addon->log_debug( __METHOD__ . '(): Verifying reCAPTCHA submission.' ); if ( empty( $token ) ) { $this->addon->log_debug( __METHOD__ . '(): Could not verify the submission because no token was found.' . PHP_EOL ); return false; } $plugin_settings = $this->addon->get_plugin_settings(); $connection_type = rgar( $plugin_settings, 'connection_type' ); $this->init( $token, 'submit', $connection_type ); if ( $connection_type !== 'enterprise' ) { $response = $this->get_response_data( $this->api->verify_token( $token, $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' ) ) ); } else { $access_token = rgar( $plugin_settings, 'access_token' ); $project_id = $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'project_number' ); $response = $this->api->create_recaptcha_assessment( $access_token, $project_id, $token, $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'site_key_v3_enterprise' ), $action = 'submit' ); } if ( is_wp_error( $response ) ) { $this->addon->log_debug( __METHOD__ . '(): Validating the reCAPTCHA response has failed due to the following: ' . $response->get_error_message() ); wp_send_json_error( array( 'error' => $data->get_error_message(), 'code' => self::ERROR_CODE_CANNOT_VERIFY_TOKEN, ) ); } if ( isset( $response->score ) && $response->score === 'disabled (quota limit)' ) { $this->addon->log_debug( __METHOD__ . '(): Validation bypassed due to reCAPTCHA quota limit.' ); $this->recaptcha_result = $response; return true; } if ( ! $this->validate_response_data( $response, $connection_type ) ) { $this->addon->log_debug( __METHOD__ . '(): Could not validate the token request from the reCAPTCHA service. ' . PHP_EOL . "token: {$token}" . PHP_EOL . "response: " . print_r( $response, true ) . PHP_EOL // @codingStandardsIgnoreLine ); return false; } // @codingStandardsIgnoreLine $this->addon->log_debug( __METHOD__ . '(): Validated reCAPTCHA: ' . print_r( $response, true ) ); $this->recaptcha_result = $response; // Caching result for 1 hour. \GFCache::set( 'recaptcha_' . $token, $response, true, 60 * 60 ); return true; } /** * Get the data from the response. * * @since 1.0 * * @param WP_Error|string $response The response from the API request. * * @return mixed */ private function get_response_data( $response ) { if ( is_wp_error( $response ) ) { return $response; } $response_code = wp_remote_retrieve_response_code( $response ); /** * If the reCAPTCHA API quota has been exceeded, a 429 status code * is returned. This will fake a successful response to prevent * the form from being blocked by a reCAPTCHA quota limit. */ if ( $response_code === 429 ) { $this->addon->log_debug( __METHOD__ . '(): reCAPTCHA API quota limit exceeded.' ); update_option( GF_RECAPTCHA::RECAPTCHA_QUOTA_LIMIT_HIT, true ); $data = new stdClass; $data->success = true; $data->challenge_ts = date( 'c' ); $data->hostname = wp_parse_url( get_home_url(), PHP_URL_HOST ); $data->score = 'disabled (quota limit)'; $data->action = $this->action; return $data; } return json_decode( wp_remote_retrieve_body( $response ) ); } /** * Verify the reCAPTCHA hostname. * * @since 1.0 * * @param string $hostname Verify that the host name returned matches the site. * * @return bool */ private function verify_hostname( $hostname ) { if ( ! has_filter( 'gform_recaptcha_valid_hostnames' ) ) { $this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter not implemented. Skipping.' ); return true; } $this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter detected. Verifying hostname.' ); /** * Filter for the set of hostnames considered valid by this site. * * Google returns a 'hostname' value in reCAPTCHA verification results. We validate against this value to ensure * that the data is good. By default, we use only the WordPress installation's home URL, but have extended * this via a filter so developers can define an array of hostnames to allow. * * @since 1.0 * * @param array $valid_hostnames { * An indexed array of valid hostname strings. Example: * array( 'example.com', 'another-example.com' ) * } */ $valid_hostnames = apply_filters( 'gform_recaptcha_valid_hostnames', array( wp_parse_url( get_home_url(), PHP_URL_HOST ), ) ); return is_array( $valid_hostnames ) ? in_array( $hostname, $valid_hostnames, true ) : false; } /** * Verify the reCAPTCHA action. * * @since 1.0 * * @param string $action The reCAPTCHA result action. * * @return bool */ private function verify_action( $action ) { $this->addon->log_debug( __METHOD__ . '(): Verifying action from reCAPTCHA response.' ); return $this->action === $action; } /** * Verify that the score is valid. * * @since 1.0 * * @param float $score The reCAPTCHA v3 score. * * @return bool */ private function verify_score( $score ) { $this->addon->log_debug( __METHOD__ . '(): Verifying score from reCAPTCHA response.' ); if ( $score === 'disabled (quota limit)' ) { $this->addon->log_debug( __METHOD__ . '(): Score verfication bypassed due to exceeding the reCAPTCHA API quota limit.' ); return true; } return ( is_numeric( $score ) && $score >= 0.0 && $score <= 1.0 ); } /** * Verify that the timestamp of the submission is valid. * * Google allows a reCAPTCHA token to be valid for two minutes. On multi-page forms, we generate a new token with * the advancement of each page, but the timestamp that's returned is always the same. Thus, we'll allow a longer * time frame for form submissions before considering them to be invalid. * * @since 1.0 * * @param string $challenge_ts The challenge timestamp from the reCAPTCHA service. * * @return bool */ private function verify_timestamp( $challenge_ts ) { $this->addon->log_debug( __METHOD__ . '(): Verifying timestamp from reCAPTCHA response.' ); return ( gmdate( time() ) - strtotime( $challenge_ts ) ) <= 24 * HOUR_IN_SECONDS; } /** * Get the score from the Recaptcha result. * * @since 1.0 * * @return float */ public function get_score() { if ( empty( $this->recaptcha_result ) || ( ! rgars( $this->recaptcha_result, 'riskAnalysis/score' ) && ! property_exists( $this->recaptcha_result, 'score' ) ) ) { return $this->addon->is_preview() ? 0.9 : 0.0; } if ( rgars( $this->recaptcha_result, 'riskAnalysis/score' ) ) { $score = rgars( $this->recaptcha_result, 'riskAnalysis/score' ); } else { $score = $this->recaptcha_result->score; } return (float) $score; } /** * Gets the assessment ID (name) from the reCAPTCHA assessment response. * * @since 1.10 * * @return string */ public function get_assessment_id() { return $this->addon->is_preview() ? '' : rgar( $this->recaptcha_result, 'name', '' ); } /** * Get the decoded response data from the API. * * @param string $token The validation token. * @param string $secret The stored secret key from the settings page. * * @since 1.0 * * @return WP_Error|mixed|string */ public function verify( $token, $secret ) { return $this->get_response_data( $this->api->verify_token( $token, $secret ) ); } }