Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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' ) ) {