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