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