diff --git a/.env.example b/.env.example index 7b919f2e8..ddce08124 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,5 @@ CONVERTKIT_API_SIGNED_SUBSCRIBER_ID= CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS= CONVERTKIT_API_RECAPTCHA_SITE_KEY= CONVERTKIT_API_RECAPTCHA_SECRET_KEY= +CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY= +CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY= diff --git a/.github/workflows/_run-tests.yml b/.github/workflows/_run-tests.yml index 934923c06..c6c0b9579 100644 --- a/.github/workflows/_run-tests.yml +++ b/.github/workflows/_run-tests.yml @@ -75,6 +75,8 @@ jobs: CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS: ${{ secrets.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS }} CONVERTKIT_API_RECAPTCHA_SITE_KEY: ${{ secrets.CONVERTKIT_API_RECAPTCHA_SITE_KEY }} CONVERTKIT_API_RECAPTCHA_SECRET_KEY: ${{ secrets.CONVERTKIT_API_RECAPTCHA_SECRET_KEY }} + CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY: ${{ secrets.CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY }} + CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY: ${{ secrets.CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY }} steps: - name: Define Test Group Name @@ -291,6 +293,8 @@ jobs: CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS=${{ env.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS }} CONVERTKIT_API_RECAPTCHA_SITE_KEY=${{ env.CONVERTKIT_API_RECAPTCHA_SITE_KEY }} CONVERTKIT_API_RECAPTCHA_SECRET_KEY=${{ env.CONVERTKIT_API_RECAPTCHA_SECRET_KEY }} + CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY=${{ env.CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY }} + CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY=${{ env.CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY }} ENVEOF # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index dc10bbb5b..95d4af4da 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -59,22 +59,22 @@ public function __construct() { // Define settings sections. $this->settings_sections = array( - 'general' => array( + 'general' => array( 'title' => $this->title, 'callback' => array( $this, 'print_section_info' ), 'wrap' => true, ), - 'site-wide' => array( + 'site-wide' => array( 'title' => __( 'Non-inline Forms', 'convertkit' ), 'callback' => array( $this, 'print_section_info_site_wide' ), 'wrap' => true, ), - 'recaptcha' => array( - 'title' => __( 'reCAPTCHA', 'convertkit' ), - 'callback' => array( $this, 'print_section_info_recaptcha' ), + 'spam-protection' => array( + 'title' => __( 'Spam Protection', 'convertkit' ), + 'callback' => array( $this, 'print_section_info_spam_protection' ), 'wrap' => true, ), - 'advanced' => array( + 'advanced' => array( 'title' => __( 'Advanced', 'convertkit' ), 'callback' => array( $this, 'print_section_info_advanced' ), 'wrap' => true, @@ -409,15 +409,31 @@ public function register_fields() { ) ); + // Spam Protection. + add_settings_field( + 'spam_protection_provider', + __( 'Provider', 'convertkit' ), + array( $this, 'spam_protection_provider_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'spam_protection_provider', + 'description' => array( + __( 'Select which spam protection service to use for the Member Content signup form and Form Builder block.', 'convertkit' ), + ), + ) + ); + // reCAPTCHA. add_settings_field( 'recaptcha_site_key', __( 'reCAPTCHA: Site Key', 'convertkit' ), array( $this, 'recaptcha_site_key_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_site_key', + 'class' => 'convertkit-spam-protection-recaptcha', 'description' => array( __( 'Enter your Google reCAPTCHA v3 Site Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), @@ -428,9 +444,10 @@ public function register_fields() { __( 'reCAPTCHA: Secret Key', 'convertkit' ), array( $this, 'recaptcha_secret_key_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_secret_key', + 'class' => 'convertkit-spam-protection-recaptcha', 'description' => array( __( 'Enter your Google reCAPTCHA v3 Secret Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), @@ -441,9 +458,10 @@ public function register_fields() { __( 'reCAPTCHA: Minimum Score', 'convertkit' ), array( $this, 'recaptcha_minimum_score_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_minimum_score', + 'class' => 'convertkit-spam-protection-recaptcha', 'min' => 0, 'max' => 1, 'step' => 0.01, @@ -453,6 +471,36 @@ public function register_fields() { ) ); + // Cloudflare Turnstile. + add_settings_field( + 'cloudflare_turnstile_site_key', + __( 'Cloudflare Turnstile: Site Key', 'convertkit' ), + array( $this, 'cloudflare_turnstile_site_key_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'cloudflare_turnstile_site_key', + 'class' => 'convertkit-spam-protection-cloudflare_turnstile', + 'description' => array( + __( 'Enter your Cloudflare Turnstile Site Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), + ), + ) + ); + add_settings_field( + 'cloudflare_turnstile_secret_key', + __( 'Cloudflare Turnstile: Secret Key', 'convertkit' ), + array( $this, 'cloudflare_turnstile_secret_key_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'cloudflare_turnstile_secret_key', + 'class' => 'convertkit-spam-protection-cloudflare_turnstile', + 'description' => array( + __( 'Enter your Cloudflare Turnstile Secret Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), + ), + ) + ); + // Advanced. add_settings_field( 'debug', @@ -547,13 +595,13 @@ public function print_section_info_site_wide() { } /** - * Prints help info for the recaptcha section of the settings screen. + * Prints help info for the spam protection section of the settings screen. * - * @since 3.0.0 + * @since 3.3.7 */ - public function print_section_info_recaptcha() { + public function print_section_info_spam_protection() { ?> -

+

output_select_field( + 'spam_protection_provider', + esc_attr( $this->settings->spam_protection_provider() ), + array( + 'recaptcha' => esc_html__( 'Google reCAPTCHA v3', 'convertkit' ), + 'cloudflare_turnstile' => esc_html__( 'Cloudflare Turnstile', 'convertkit' ), + ), + $args['description'], + array( 'convertkit-conditional-display' ), + array( 'data-conditional-class-prefix' => 'convertkit-spam-protection-' ) + ); + + } + /** * Renders the input for the reCAPTCHA Site Key setting. * @@ -985,6 +1056,46 @@ public function recaptcha_minimum_score_callback( $args ) { } + /** + * Renders the input for the Cloudflare Turnstile Site Key setting. + * + * @since 3.3.7 + * + * @param array $args Setting field arguments (name,description). + */ + public function cloudflare_turnstile_site_key_callback( $args ) { + + $this->output_text_field( + 'cloudflare_turnstile_site_key', + esc_attr( $this->settings->cloudflare_turnstile_site_key() ), + $args['description'], + array( + 'widefat', + ) + ); + + } + + /** + * Renders the input for the Cloudflare Turnstile Secret Key setting. + * + * @since 3.3.7 + * + * @param array $args Setting field arguments (name,description). + */ + public function cloudflare_turnstile_secret_key_callback( $args ) { + + $this->output_text_field( + 'cloudflare_turnstile_secret_key', + esc_attr( $this->settings->cloudflare_turnstile_secret_key() ), + $args['description'], + array( + 'widefat', + ) + ); + + } + /** * Renders the input for the Debug setting. * diff --git a/includes/blocks/class-convertkit-block-form-builder.php b/includes/blocks/class-convertkit-block-form-builder.php index 16833a0fd..8c0687a4d 100644 --- a/includes/blocks/class-convertkit-block-form-builder.php +++ b/includes/blocks/class-convertkit-block-form-builder.php @@ -77,15 +77,11 @@ public function maybe_subscribe() { return; } - // Check reCAPTCHA. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha_response = $recaptcha->verify_recaptcha( - ( isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : '' ), - 'convertkit_form_builder' - ); + // Check spam protection. + $spam_protection = new ConvertKit_Spam_Protection(); - // Bail if reCAPTCHA failed. - if ( is_wp_error( $recaptcha_response ) ) { + // Bail if spam protection failed. + if ( is_wp_error( $spam_protection->verify( 'convertkit_form_builder' ) ) ) { return; } @@ -721,23 +717,23 @@ public function render_form_button( $block_content, $block ) { $block_content ); - // Return the button if reCAPTCHA does not need to be used. - $settings = new ConvertKit_Settings(); - if ( ! $settings->has_recaptcha_site_and_secret_keys() ) { + // Return the button if no spam protection provider is active. + $spam_protection = new ConvertKit_Spam_Protection(); + $provider = $spam_protection->get_active_provider(); + if ( ! $provider ) { return $block_content; } - // Enqueue reCAPTCHA JS. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha->enqueue_scripts(); + // Enqueue the spam protection provider's JS. + $provider->enqueue_scripts(); - // Add reCAPTCHA attributes to button. + // Parse the button's DOM. $parser = new ConvertKit_HTML_Parser( $block_content ); $button = $parser->xpath->query( '//button' )->item( 0 ); - $button->setAttribute( 'data-sitekey', esc_attr( $settings->recaptcha_site_key() ) ); // @phpstan-ignore-line - $button->setAttribute( 'data-callback', 'convertKitRecaptchaFormSubmit' ); // @phpstan-ignore-line - $button->setAttribute( 'data-action', 'convertkit_form_builder' ); // @phpstan-ignore-line - $button->setAttribute( 'class', trim( $button->getAttribute( 'class' ) . ' g-recaptcha' ) ); // @phpstan-ignore-line + + // Attach the spam protection provider's attributes/elements to the form/button as necessary. + // $button is narrowed from DOMNode to DOMElement by the //button xpath expression above. + $provider->attach_to_form_button_dom( $parser, $button, 'convertkit_form_builder' ); // @phpstan-ignore-line // Return button HTML. return $parser->get_body_html(); diff --git a/includes/class-convertkit-cloudflare-turnstile.php b/includes/class-convertkit-cloudflare-turnstile.php new file mode 100644 index 000000000..b87aa7320 --- /dev/null +++ b/includes/class-convertkit-cloudflare-turnstile.php @@ -0,0 +1,203 @@ +settings = new ConvertKit_Settings(); + + } + + /** + * Enqueues the Cloudflare Turnstile client-side script if Cloudflare Turnstile + * site and secret keys are set and scripts are enabled. + * + * @since 3.3.7 + */ + public function enqueue_scripts() { + + // Don't run if Cloudflare Turnstile or scripts are disabled. + if ( ! $this->settings->has_cloudflare_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + return; + } + + // Enqueue Cloudflare Turnstile JS. + add_filter( + 'convertkit_output_scripts_footer', + function ( $scripts ) { + + $scripts[] = array( + 'src' => self::CLIENT_SCRIPT_URL, + 'async' => true, + 'defer' => true, + ); + + return $scripts; + + } + ); + + } + + /** + * Verifies a Cloudflare Turnstile response token against the Siteverify API, + * if Cloudflare Turnstile site and secret keys are set, and scripts are enabled. + * + * Mirrors the request format documented at + * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + * + * @since 3.3.7 + * + * @param string $cloudflare_turnstile_response Cloudflare Turnstile response token from the client. + * @param string $plugin_action Plugin action string (unused). + * @return bool|WP_Error + */ + public function verify( $cloudflare_turnstile_response, $plugin_action ) { + + unset( $plugin_action ); + + // Don't run if Turnstile or scripts are disabled. + if ( ! $this->settings->has_cloudflare_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + return true; + } + + // POST to Cloudflare Siteverify. + $response = wp_remote_post( + self::SITEVERIFY_URL, + array( + 'body' => array( + 'secret' => $this->settings->cloudflare_turnstile_secret_key(), + 'response' => $cloudflare_turnstile_response, + 'remoteip' => ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ), + ), + ) + ); + + // Bail if the request itself errored. + if ( is_wp_error( $response ) ) { + return $response; + } + + // Decode response. + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // If the response body couldn't be decoded, treat that as a failure. + if ( ! is_array( $body ) ) { + return new WP_Error( + 'convertkit_cloudflare_turnstile_failed', + __( 'Cloudflare Turnstile failure: invalid response from Siteverify.', 'convertkit' ) + ); + } + + // If the token verified, return true. + if ( $body['success'] === true ) { + return true; + } + + // Return an error. + return new WP_Error( + 'convertkit_cloudflare_turnstile_failed', + sprintf( + /* translators: Error codes */ + __( 'Cloudflare Turnstile failure: %s', 'convertkit' ), + implode( ', ', $body['error-codes'] ) + ) + ); + + } + + /** + * Inserts a Cloudflare Turnstile widget div immediately before the given + * submit button within an existing DOM tree. `data-appearance=interaction-only` + * keeps the widget invisible unless Cloudflare determines a challenge is + * required, and the `convertKitTurnstileFormSubmit` callback submits the + * enclosing form once the challenge is solved. + * + * @since 3.3.7 + * + * @param ConvertKit_HTML_Parser $parser Parser wrapping the DOM. + * @param DOMElement $button