Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/kirki/ComponentLibrary/controller/CompLibFormHandler.php
+++ b/kirki/ComponentLibrary/controller/CompLibFormHandler.php
@@ -151,7 +151,10 @@
$form_data = $request->get_body_params();
$transient_name = $this->validate_nonce( 'kirki-comment' ); // note: typo fix from $transiet_name
- $comment = isset( $form_data['comment'] ) ? sanitize_text_field( $form_data['comment'] ) : '';
+ // FIX 3: decode entities *before* sanitising. sanitize_text_field() strips
+ // tags but leaves entity-encoded markup (`<script>`) intact, which is
+ // how markup used to survive the write and reach the renderer.
+ $comment = isset( $form_data['comment'] ) ? sanitize_text_field( html_entity_decode( (string) $form_data['comment'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) : '';
$post_id = isset( $form_data['post_id'] ) ? absint( $form_data['post_id'] ) : 0;
$comment_parent = isset( $form_data['comment_parent'] ) ? absint( $form_data['comment_parent'] ) : 0;
$user_id = get_current_user_id();
@@ -163,7 +166,7 @@
$email = $user->get( 'user_email' );
} else {
// Anonymous commenter: require name + valid email supplied in the form.
- $name = isset( $form_data['name'] ) ? sanitize_text_field( $form_data['name'] ) : '';
+ $name = isset( $form_data['name'] ) ? sanitize_text_field( html_entity_decode( (string) $form_data['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) : '';
$email = isset( $form_data['email'] ) ? sanitize_email( $form_data['email'] ) : '';
if ( empty( $name ) || empty( $email ) || ! is_email( $email ) ) {
@@ -210,19 +213,28 @@
);
}
- $date = gmdate( 'Y-m-d H:i:s' );
+ $date = current_time( 'mysql' );
- global $wpdb;
- $wpdb->update(
- $wpdb->comments,
+ // FIX 3 (cont.): go through wp_update_comment() instead of a raw
+ // $wpdb->update(), so the same kses/comment filters that guard
+ // wp_new_comment() also guard edits.
+ $updated = wp_update_comment(
array(
+ 'comment_ID' => $existing_comment_id,
'comment_content' => $comment,
'comment_date' => $date,
'comment_date_gmt' => get_gmt_from_date( $date ),
),
- array( 'comment_ID' => $existing_comment_id )
+ true
);
+ if ( is_wp_error( $updated ) ) {
+ return new WP_REST_Response(
+ array( 'message' => $updated->get_error_message() ),
+ 400
+ );
+ }
+
apply_filters(
'kirki_comment_added-' . $collection_type,
array(
--- a/kirki/ComponentLibrary/controller/ElementGenerator.php
+++ b/kirki/ComponentLibrary/controller/ElementGenerator.php
@@ -16,6 +16,7 @@
private $options = array();
private $generate_child_element = null;
private $get_data_and_styles_from_root = null;
+ private $get_collection_info = array();
private $style_blocks = array();
private $properties = array();
public $component_lib_forms = array();
@@ -34,6 +35,7 @@
$this->setting = $this->properties['settings'];
$this->component_lib_forms = $props['component_lib_forms'];
$this->get_data_and_styles_from_root = $props['get_data_and_styles_from_root'];
+ $this->get_collection_info = $props['get_collection_info'];
$this->style_blocks = $props['style_blocks'];
$this->add_element_config();
}
@@ -204,7 +206,10 @@
'styles' => array(),
'root' => $this->element['parentId'],
);
- call_user_func_array( $this->get_data_and_styles_from_root, array( $this->element['parentId'], &$data_n_styles, &$this->elements, &$this->style_blocks ) );
+
+ $data_n_styles = call_user_func_array( $this->get_collection_info, array( $this->options, $this->element ) );
+
+ // call_user_func_array( $this->get_data_and_styles_from_root, array( $this->element['parentId'], &$data_n_styles, &$this->elements, &$this->style_blocks ) );
$encoded_data = json_encode( $data_n_styles );
$kirki_data .= "<textarea data-type='kirki_data' style='display: none'>" . esc_textarea( $encoded_data ) . '</textarea>';
}
--- a/kirki/app/FormActions/Actions/EmailActionHandler.php
+++ b/kirki/app/FormActions/Actions/EmailActionHandler.php
@@ -23,6 +23,10 @@
/**
* Register shortcodes referenced by the email action's fields.
*
+ * Every field is rendered through {@see render_email_action_text()}, which
+ * strips any shortcode that does not map to a submitted form field, so no
+ * arbitrary site shortcode can execute here.
+ *
* @param array $action
* @param array $form_data
* @return void
@@ -86,15 +90,15 @@
}
if (isset($email_action['replyTo'])) {
- $reply_to = do_shortcode($email_action['replyTo']);
+ $reply_to = sanitize_email($this->render_email_action_text($email_action['replyTo'], $form_data));
}
if (isset($email_action['name'])) {
- $name = do_shortcode($email_action['name']);
+ $name = $this->render_email_action_text($email_action['name'], $form_data);
}
if (isset($email_action['subject'])) {
- $subject = do_shortcode($email_action['subject']);
+ $subject = $this->render_email_action_text($email_action['subject'], $form_data);
}
if (strlen($reply_to) > 0 && strlen($name) > 0) {
@@ -102,13 +106,78 @@
}
if (isset($email_action['emailList']) && !empty($email_action['emailList'])) {
- return $this->send_email_notification(do_shortcode($email_action['emailList']), $subject, $body, $header);
+ $to = $this->sanitize_email_list($this->render_email_action_text($email_action['emailList'], $form_data));
+
+ if ($to) {
+ return $this->send_email_notification($to, $subject, $body, $header);
+ }
}
return false;
}
/**
+ * Render a templated email action field using an explicit whitelist array.
+ *
+ * @param string $value The configured field value.
+ * @param array $form_data The submitted form data.
+ * @return string
+ */
+ protected function render_email_action_text($value, $form_data)
+ {
+ if (empty($value) || !is_string($value)) {
+ return (string) $value;
+ }
+
+ // Whitelist array of acceptable email action shortcodes and their resolved values.
+ $whitelist = [
+ 'admin_email' => (string) get_option('admin_email'),
+ ];
+
+ if (is_array($form_data)) {
+ foreach ($form_data as $field_name => $field_value) {
+ if (is_array($field_value)) {
+ $whitelist[$field_name] = implode(', ', $field_value);
+ } else {
+ $whitelist[$field_name] = (string) $field_value;
+ }
+ }
+ }
+
+ // Render only the whitelisted shortcodes from the array
+ foreach ($whitelist as $tag => $replacement) {
+ $value = str_replace('[' . $tag . ']', $replacement, $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Sanitize a comma-separated list of email addresses.
+ *
+ * @param string $value The configured email list value.
+ * @return string A comma-separated string of valid addresses, or '' when empty.
+ */
+ protected function sanitize_email_list($value)
+ {
+ if (empty($value) || !is_string($value)) {
+ return '';
+ }
+
+ $valid = [];
+
+ foreach (explode(',', $value) as $address) {
+ $address = sanitize_email(trim($address));
+
+ if ($address) {
+ $valid[] = $address;
+ }
+ }
+
+ return implode(', ', $valid);
+ }
+
+ /**
* Build the email body from a body configuration.
*
* @param array $body_config Body configuration.
--- a/kirki/app/FormActions/Actions/WebhookActionHandler.php
+++ b/kirki/app/FormActions/Actions/WebhookActionHandler.php
@@ -2,6 +2,8 @@
namespace KirkiAppFormActionsActions;
+use KirkiHelperFunctions;
+
defined('ABSPATH') || exit;
use KirkiAppConstantsFormFormWebhookMethods;
@@ -44,6 +46,10 @@
*/
protected function send_get($url, $form_data)
{
+ if (!HelperFunctions::is_safe_url($url)) {
+ return false;
+ }
+
$query_string = http_build_query($form_data);
$url = rtrim($url, '/');
$url .= '/';
@@ -62,6 +68,10 @@
*/
protected function send_post($url, $form_data)
{
+ if (!HelperFunctions::is_safe_url($url)) {
+ return false;
+ }
+
$response = Http::as_form()->post($url, $form_data);
return $response->status() !== 0;
--- a/kirki/app/Services/FormSubmissionService.php
+++ b/kirki/app/Services/FormSubmissionService.php
@@ -109,7 +109,10 @@
}
/**
- * Parse and decode the base64 form metadata token.
+ * Parse and verify the base64 form metadata token.
+ *
+ * The token is signed with `wp_hash()` at render time, so an attacker
+ * cannot mint tokens for arbitrary form/post combinations.
*
* @param mixed $form_meta_data_base64 Base64 encoded form metadata.
* @return array{form_id: string|null, post_id: string|null}
@@ -120,11 +123,26 @@
return ['form_id' => null, 'post_id' => null];
}
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
+ if (count($form_meta_data) < 3) {
+ return ['form_id' => null, 'post_id' => null];
+ }
+
+ $form_id = $form_meta_data[0];
+ $post_id = $form_meta_data[1];
+ $signature = $form_meta_data[2];
+
+ $expected = wp_hash($form_id . '|' . $post_id);
+
+ if (!hash_equals($expected, (string) $signature)) {
+ return ['form_id' => null, 'post_id' => null];
+ }
+
return [
- 'form_id' => $form_meta_data[0] ?: null,
- 'post_id' => $form_meta_data[1] ?: null,
+ 'form_id' => $form_id ?: null,
+ 'post_id' => $post_id ?: null,
];
}
--- a/kirki/includes/API/Frontend/Controllers/CollectionController.php
+++ b/kirki/includes/API/Frontend/Controllers/CollectionController.php
@@ -12,11 +12,15 @@
exit; // Exit if accessed directly.
}
+use KirkiAjaxSymbol;
use KirkiAjaxUsers;
use KirkiFrontendPreviewPreview;
use KirkiHelperFunctions;
use WP_REST_Server;
-
+use KirkiAppModelsPage as PageModel;
+use KirkiAppResourcesPageContentResource;
+use KirkiAppSupportsFacadesPage;
+use KirkiFrontendPreviewDataHelper;
/**
* CollectionController
@@ -87,6 +91,31 @@
);
}
+ private function prepare_kirki_data_for_collection($kirki_data){
+ $data_n_styles = [];
+ $post_id = (int) $kirki_data['post_id'];
+ $collection_data_id = $kirki_data['collection_data_id'];
+ $post = get_post($post_id);
+ if($post){
+ if($post->post_type === 'kirki_symbol'){
+ $symbol = Symbol::get_single_symbol( $post_id, true, false, array() );
+
+ if ( $symbol ) {
+ $symbol_data = $symbol['symbolData'];
+ if ( $symbol_data && isset( $symbol_data['data'] ) ) {
+ $all_blocks = get_post_meta($post_id, 'kirki', true);
+ $all_styles = Page::get_page_styleblocks($post_id, false);
+ DataHelper::get_data_and_styles_from_root( $collection_data_id, $data_n_styles, $symbol_data['data'], $symbol_data['styleBlocks'] );
+ }
+ }
+ }else {
+ $all_blocks = get_post_meta($post_id, 'kirki', true);
+ $all_styles = Page::get_page_styleblocks($post_id, false);
+ DataHelper::get_data_and_styles_from_root( $collection_data_id, $data_n_styles, $all_blocks['blocks'], $all_styles );
+ }
+ }
+ return $data_n_styles;
+ }
/**
* Creates a collection page
*
@@ -97,7 +126,7 @@
public function get_collection( $request ) {
$page = absint( $request->get_param( 'page' ) );
$page = empty( $page ) ? 1 : $page;
- $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
+ // $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
$collection_param_filters = json_decode( $request->get_param( 'filters' ), true );
$kirki_data = json_decode( $request->get_param( 'kirki_data' ), true );
$context = json_decode( $request->get_param( 'context' ), true );
@@ -107,8 +136,11 @@
$kirki_data = is_array( $kirki_data ) ? $kirki_data : array();
$context = is_array( $context ) ? $context : array();
- $blocks = $kirki_data['blocks'] ?? array();
- $styles = $kirki_data['styles'] ?? array();
+ $collection_id = $kirki_data['collection_data_id'] ?? false;
+ $data_n_styles = $this->prepare_kirki_data_for_collection($kirki_data);
+
+ $blocks = $data_n_styles['blocks'] ?? array();
+ $styles = $data_n_styles['styles'] ?? array();
$options = array();
if ( $context ) {
@@ -165,12 +197,16 @@
$page = absint( $request->get_param( 'page' ) );
$page = empty( $page ) ? 1 : $page;
- $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
+ // $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
$post_id = HelperFunctions::sanitize_text( $request->get_param( 'post_id' ) );
$kirki_data = json_decode( $request->get_param( 'kirki_data' ), true );
+ $kirki_data = is_array( $kirki_data ) ? $kirki_data : array();
+
+ $collection_id = $kirki_data['collection_data_id'] ?? false;
+ $data_n_styles = $this->prepare_kirki_data_for_collection($kirki_data);
- $blocks = $kirki_data['blocks'];
- $styles = $kirki_data['styles'];
+ $blocks = $data_n_styles['blocks'] ?? array();
+ $styles = $data_n_styles['styles'] ?? array();
$params = array(
'blocks' => $blocks,
@@ -198,13 +234,17 @@
public function get_users( $request ) {
$page = absint( $request->get_param( 'page' ) );
$page = empty( $page ) ? 1 : $page;
- $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
+ // $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
$post_id = HelperFunctions::sanitize_text( $request->get_param( 'post_id' ) );
$kirki_data = json_decode( $request->get_param( 'kirki_data' ), true );
$query = HelperFunctions::sanitize_text( $request->get_param( 'q' ) );
+ $kirki_data = is_array( $kirki_data ) ? $kirki_data : array();
+
+ $collection_id = $kirki_data['collection_data_id'] ?? false;
+ $data_n_styles = $this->prepare_kirki_data_for_collection($kirki_data);
- $blocks = $kirki_data['blocks'];
- $styles = $kirki_data['styles'];
+ $blocks = $data_n_styles['blocks'] ?? array();
+ $styles = $data_n_styles['styles'] ?? array();
$params = array(
'blocks' => $blocks,
@@ -225,18 +265,25 @@
/**
* Gets terms of a collection
+ *
+ * @param WP_REST_Request $request all user request parameter.
+ *
+ * @return WP_Error|WP_REST_Response
*/
-
public function get_terms( $request ) {
$page = absint( $request->get_param( 'page' ) );
$page = empty( $page ) ? 1 : $page;
- $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
+ // $collection_id = HelperFunctions::sanitize_text( $request->get_param( 'collection_id' ) );
$post_id = HelperFunctions::sanitize_text( $request->get_param( 'post_id' ) );
$kirki_data = json_decode( $request->get_param( 'kirki_data' ), true );
+ $kirki_data = is_array( $kirki_data ) ? $kirki_data : array();
+
+ $collection_id = $kirki_data['collection_data_id'] ?? false;
+ $data_n_styles = $this->prepare_kirki_data_for_collection($kirki_data);
- $blocks = $kirki_data['blocks'];
- $styles = $kirki_data['styles'];
+ $blocks = $data_n_styles['blocks'] ?? array();
+ $styles = $data_n_styles['styles'] ?? array();
$params = array(
'blocks' => $blocks,
--- a/kirki/includes/API/Frontend/Controllers/FormController.php
+++ b/kirki/includes/API/Frontend/Controllers/FormController.php
@@ -553,7 +553,7 @@
}
/**
- * Parse and validate form metadata
+ * Parse and verify form metadata
*
* @param string $form_meta_data_base64 Base64 encoded form metadata.
* @return array Array with form_id and post_id.
@@ -563,9 +563,29 @@
//phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
+ if (count($form_meta_data) < 3) {
+ return array(
+ 'form_id' => null,
+ 'post_id' => null,
+ );
+ }
+
+ $form_id = $form_meta_data[0];
+ $post_id = $form_meta_data[1];
+ $signature = $form_meta_data[2];
+
+ $expected = wp_hash($form_id . '|' . $post_id);
+
+ if (!hash_equals($expected, (string) $signature)) {
+ return array(
+ 'form_id' => null,
+ 'post_id' => null,
+ );
+ }
+
return array(
- 'form_id' => isset($form_meta_data[0]) ? $form_meta_data[0] : null,
- 'post_id' => isset($form_meta_data[1]) ? $form_meta_data[1] : null,
+ 'form_id' => $form_id ?: null,
+ 'post_id' => $post_id ?: null,
);
}
@@ -685,13 +705,13 @@
}
if (isset($email_action['replyTo'])) {
- $replyTo = do_shortcode($email_action['replyTo']);
+ $replyTo = sanitize_email($this->render_email_action_text($email_action['replyTo'], $form_data));
}
if (isset($email_action['name'])) {
- $name = do_shortcode($email_action['name']);
+ $name = $this->render_email_action_text($email_action['name'], $form_data);
}
if (isset($email_action['subject'])) {
- $subject = do_shortcode($email_action['subject']);
+ $subject = $this->render_email_action_text($email_action['subject'], $form_data);
}
if (strlen($replyTo) > 0 && strlen($name) > 0) {
@@ -699,8 +719,73 @@
}
if (isset($email_action['emailList']) && !empty($email_action['emailList'])) {
- $this->send_email_notification(do_shortcode($email_action['emailList']), $subject, $body, $header);
+ $to = $this->sanitize_email_list($this->render_email_action_text($email_action['emailList'], $form_data));
+
+ if ($to) {
+ $this->send_email_notification($to, $subject, $body, $header);
+ }
+ }
+ }
+
+ /**
+ * Render a templated email action field using an explicit whitelist array.
+ *
+ * @param string $value The configured field value.
+ * @param array $form_data The submitted form data.
+ * @return string
+ */
+ private function render_email_action_text($value, $form_data)
+ {
+ if (empty($value) || !is_string($value)) {
+ return (string) $value;
+ }
+
+ // Whitelist array of acceptable email action shortcodes and their resolved values.
+ $whitelist = array(
+ 'admin_email' => (string) get_option('admin_email'),
+ );
+
+ if (is_array($form_data)) {
+ foreach ($form_data as $field_name => $field_value) {
+ if (is_array($field_value)) {
+ $whitelist[$field_name] = implode(', ', $field_value);
+ } else {
+ $whitelist[$field_name] = (string) $field_value;
+ }
+ }
+ }
+
+ // Render only the whitelisted shortcodes from the array
+ foreach ($whitelist as $tag => $replacement) {
+ $value = str_replace('[' . $tag . ']', $replacement, $value);
}
+
+ return $value;
+ }
+
+ /**
+ * Sanitize a comma-separated list of email addresses.
+ *
+ * @param string $value The configured email list value.
+ * @return string A comma-separated string of valid addresses, or '' when empty.
+ */
+ private function sanitize_email_list($value)
+ {
+ if (empty($value) || !is_string($value)) {
+ return '';
+ }
+
+ $valid = array();
+
+ foreach (explode(',', $value) as $address) {
+ $address = sanitize_email(trim($address));
+
+ if ($address) {
+ $valid[] = $address;
+ }
+ }
+
+ return implode(', ', $valid);
}
/**
@@ -764,6 +849,10 @@
*/
private function send_webhook_get($url, $form_data)
{
+ if (!HelperFunctions::is_safe_url($url)) {
+ return false;
+ }
+
$query_string = http_build_query($form_data);
if (substr($url, -1) !== '/') {
@@ -785,6 +874,10 @@
*/
private function send_webhook_post($url, $form_data)
{
+ if (!HelperFunctions::is_safe_url($url)) {
+ return false;
+ }
+
$options = array(
'method' => 'POST',
'httpversion' => '2.0',
@@ -799,6 +892,7 @@
return !is_wp_error($response);
}
+
/**
* Process mailclient actions
*
--- a/kirki/includes/API/Frontend/Controllers/FrontendRESTController.php
+++ b/kirki/includes/API/Frontend/Controllers/FrontendRESTController.php
@@ -40,17 +40,40 @@
* @return bool|WP_Error
*/
public function get_item_permissions_check( $request ) {
- // --- Check 1: context-based permission (post or comment context) ---
+ // --- Check 1: context-based permission ---
$raw_context = $request->get_param( 'context' );
if ( $raw_context ) {
$context = json_decode( $raw_context, true );
- $post_id = $this->extract_post_id_from_context( $context );
- if ( $post_id && ! $this->can_user_read_post( $post_id ) ) {
+ $context_type = $context['type'];
+
+ // For user contexts, require list_users capability.
+ if ( 'user' === $context_type ) {
+ $target_user_id = absint( $context['id'] ?? 0 );
+ // Allow access only if the requester is querying their own record OR list_users capability
+ if ( $target_user_id !== get_current_user_id() && ! current_user_can( 'list_users' ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ 'You do not have permission to read this user.',
+ array( 'status' => 403 )
+ );
+ }
+ } elseif ( 'term' === $context_type ) {
+ // Term contexts are publicly accessible (terms are public by default).
+ } elseif ( 'post' === $context_type || 'comment' === $context_type ) {
+ $post_id = $this->extract_post_id_from_context( $context );
+ if ( $post_id && ! $this->can_user_read_post( $post_id ) ) {
+ return new WP_Error(
+ 'rest_forbidden',
+ 'You do not have permission to read this post.',
+ array( 'status' => 403 )
+ );
+ }
+ } else {
return new WP_Error(
'rest_forbidden',
- 'You do not have permission to read this post.',
+ 'Unsupported context type.',
array( 'status' => 403 )
);
}
@@ -72,9 +95,10 @@
/**
* Extracts the relevant post ID from a decoded context array.
- * - For 'post' context: uses context['id']
- * - For 'comment' context: uses context['post_id'] (the parent post)
- * - For all others: no post ID to check
+ * Only handles 'post' and 'comment' types; all other types return null
+ * because they do not reference a post that requires a read-access check.
+ * NOTE: Callers MUST perform their own capability checks for non-post
+ * context types (user, term, etc.) before reaching this method.
*
* @param mixed $context Decoded JSON context.
* @return int|null Sanitized post ID, or null if not applicable.
--- a/kirki/includes/Ajax/DynamicContent.php
+++ b/kirki/includes/Ajax/DynamicContent.php
@@ -13,6 +13,7 @@
}
use KirkiAPIContentManagerContentManagerHelper;
+use KirkiFrontendPreviewUtils;
use KirkiHelperFunctions;
use function PHPSTORM_METAmap;
@@ -49,7 +50,12 @@
private static function resolve_dynamic_element_data( $content_info ) {
$content = apply_filters( 'kirki_dynamic_content', false, $content_info );
if ( $content !== false ) {
- return $content;
+ // Keep the editor preview in step with the frontend: plain-text
+ // dynamic values (comment fields) are escaped, never live markup.
+ return Utils::esc_dynamic_text_value(
+ $content,
+ isset( $content_info['dynamicContent'] ) ? $content_info['dynamicContent'] : array()
+ );
}
$dynamic_content = isset( $content_info['dynamicContent'] ) ? $content_info['dynamicContent'] : array();
--- a/kirki/includes/Ajax/ExportImport.php
+++ b/kirki/includes/Ajax/ExportImport.php
@@ -46,10 +46,28 @@
}
// filter asset_urls using base_url if base_url not included remove item
+ $upload_base_path = wp_parse_url($base_url, PHP_URL_PATH);
+ $upload_host = wp_parse_url($base_url, PHP_URL_HOST);
+
$asset_urls = array_filter(
$asset_urls,
- function ($asset_item) use ($base_url) {
- return strpos($asset_item['url'], $base_url) !== false;
+ function ($asset_item) use ($upload_base_path, $upload_host) {
+ $parsed = wp_parse_url($asset_item['url']);
+
+ if (empty($parsed['host']) || $parsed['host'] !== $upload_host) {
+ return false;
+ }
+
+ if (!isset($parsed['scheme']) || !in_array(strtolower($parsed['scheme']), array('http', 'https'), true)) {
+ return false;
+ }
+
+ $path = wp_normalize_path(rawurldecode(isset($parsed['path']) ? $parsed['path'] : ''));
+
+ // Must start with the uploads base path and contain no traversal.
+ return $upload_base_path
+ && strpos($path, $upload_base_path) === 0
+ && strpos($path, '..') === false;
}
);
@@ -175,16 +193,50 @@
}
// Step 3: Add file to the zip file
+ $uploads_real = realpath($upload_dir['basedir']);
+ $uploads_base_path = wp_normalize_path(wp_parse_url($upload_dir['baseurl'], PHP_URL_PATH));
+ $allowed_exts = array('jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'mp4', 'mov', 'webm', 'mp3', 'json', 'lottie');
+
foreach ($asset_urls as $key => $asset_item) {
$url = $asset_item['url'];
- $file_name = basename($url);
+ $parsed = wp_parse_url($url);
- $subdir_with_filename = explode('/uploads', $url)[1]; // /2021/05/1.jpg
- $file_path = $upload_dir['basedir'] . $subdir_with_filename; // /var/www/html/wp-content/uploads/2021/05/1.jpg
+ if (empty($parsed['scheme']) || !in_array(strtolower($parsed['scheme']), array('http', 'https'), true)) {
+ continue;
+ }
+
+ if (empty($parsed['host']) || $parsed['host'] !== wp_parse_url($upload_dir['baseurl'], PHP_URL_HOST)) {
+ continue;
+ }
- if (file_exists($file_path)) {
- $zip->addFile($file_path, $file_name);
+ $path = wp_normalize_path(rawurldecode(isset($parsed['path']) ? $parsed['path'] : ''));
+ if (!$uploads_base_path || strpos($path, $uploads_base_path) !== 0) {
+ continue; // not under /uploads
}
+
+ $relative = ltrim(substr($path, strlen($uploads_base_path)), '/');
+ if ('' === $relative || false !== strpos($relative, '..')) {
+ continue; // empty or traversal
+ }
+
+ $file_path = $uploads_real . DIRECTORY_SEPARATOR . $relative;
+ $real_path = realpath($file_path);
+
+ // Resolved file must exist, be a regular file, and stay inside uploads.
+ if (false === $real_path || !is_file($real_path)) {
+ continue;
+ }
+
+ if ($uploads_real && strpos($real_path, $uploads_real . DIRECTORY_SEPARATOR) !== 0) {
+ continue;
+ }
+
+ $ext = strtolower(pathinfo($real_path, PATHINFO_EXTENSION));
+ if (!in_array($ext, $allowed_exts, true)) {
+ continue; // only media/config files may be exported
+ }
+
+ $zip->addFile($real_path, basename($real_path));
}
if (false === $zip->addFile($kirki_json_file, 'kirki-data.json')) {
--- a/kirki/includes/Frontend/Preview/DataHelper.php
+++ b/kirki/includes/Frontend/Preview/DataHelper.php
@@ -67,6 +67,10 @@
$prefix = $symbol_id ? 'kirki-s' . $symbol_id : null;
$new_id = $this->get_unique_new_id( $root );
$element = $data[ $root ];
+
+ if($element['name'] === 'collection' || $element['name'] === 'slider'){
+ $element['original_id'] = $element['id'];
+ }
$element['id'] = $new_id;
if ( isset( $element['styleIds'] ) && ! empty( $element['styleIds'] ) ) {
--- a/kirki/includes/Frontend/Preview/ExceptionalElements.php
+++ b/kirki/includes/Frontend/Preview/ExceptionalElements.php
@@ -12,9 +12,7 @@
}
use KirkiAjaxSymbol;
-use KirkiAjaxUsers;
use KirkiHelperFunctions;
-use KirkiAjaxWordpressData;
/**
* ExceptionalElements Class
@@ -116,7 +114,7 @@
$children = $this->construct_items_collection_markup( $dynamic_content, $this_data, $options );
- return $this->construct_collection_markup( $children, $this_data, $attributes, $element_name );
+ return $this->construct_collection_markup( $children, $this_data, $attributes, $element_name, $options );
}
case 'loading': {
return $this->collection_loading_element( $this_data, $attributes, $options );
@@ -530,8 +528,9 @@
$post_id = HelperFunctions::get_post_id_if_possible_from_url();
$form_id = $this_data['id'];
$post_data = $form_id . '|' . $post_id;
+ $form_data = $post_data . '|' . wp_hash( $post_data );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
- $form_id_base64 = base64_encode( base64_encode( $post_data ) );
+ $form_id_base64 = base64_encode( base64_encode( $form_data ) );
$form_nonce_field = wp_nonce_field( 'wp_rest', '_wpnonce', true, false );
@@ -770,21 +769,16 @@
* @param array $attributes single element all attributes.
* @return string HTML markup.
*/
- private function construct_collection_markup( $children, $this_data, $attributes, $element_name = 'collection' ) {
+ private function construct_collection_markup( $children, $this_data, $attributes, $element_name = 'collection', $options=[] ) {
$tag = isset( $this_data['properties']['tag'] ) ? $this_data['properties']['tag'] : 'div';
- $data_n_styles = array(
- 'blocks' => array(),
- 'styles' => array(),
- );
-
- DataHelper::get_data_and_styles_from_root( $this_data['id'], $data_n_styles, $this->data, $this->style_blocks );
+ $collection_info = $this->get_collection_info($options, $this_data);
if ( is_array( $children ) ) {
return $this->get_template(
'collection',
array(
- 'data' => $data_n_styles,
+ 'data' => $collection_info,
'attributes' => $attributes,
'children' => $children,
'tag' => $tag,
@@ -795,6 +789,20 @@
}
}
+ public function get_collection_info($options, $this_data) {
+ $collection_info = array(
+ 'post_id' => isset($options['post']) ? $options['post']->ID : false,
+ 'collection_data_id' => isset($this_data['original_id']) ? $this_data['original_id'] :$this_data['id']
+ );
+ if(isset($options['kirki_template_id']) && $options['kirki_template_id']) {
+ $collection_info['post_id'] = $options['kirki_template_id'];
+ }
+ if(isset($options['inside_symbol']) && $options['inside_symbol'] === true){
+ $collection_info['post_id'] = $options['symbol_id'];
+ }
+ return $collection_info;
+ }
+
/**
* Generate custom code markup
*
@@ -1248,6 +1256,9 @@
if ( ! $symbol_data || ! isset( $symbol_data['data'] ) ) {
return '';
}
+ $options['symbol_id'] = $symbol_id;
+ $options['inside_symbol'] = true;
+
$s = HelperFunctions::rec_update_data_id_then_return_new_html( $symbol_data['data'], $symbol_data['styleBlocks'], $symbol_data['root'], $options );
$fonts_links = '';
if ( isset( $symbol_data['customFonts'] ) ) {
--- a/kirki/includes/Frontend/Preview/Preview.php
+++ b/kirki/includes/Frontend/Preview/Preview.php
@@ -160,6 +160,8 @@
*/
private $interaction_preset_and_text_animation_tracker = array();
+ private $interaction_library_text_animation_tracker = array();
+
private $scroll_into_custom_interaction_tracker = '';
private $track_animation_for_elements_with_this_class = '';
@@ -485,12 +487,18 @@
public function get_interaction_set_as_initial_css() {
$s = '';
- if ( count( $this->interaction_preset_and_text_animation_tracker ) > 0 ) {
+ if ( count( $this->interaction_preset_and_text_animation_tracker ) > 0 || count( $this->interaction_library_text_animation_tracker ) > 0 ) {
$animation_css = '';
foreach ( $this->interaction_preset_and_text_animation_tracker as $ele_id => $bool ) {
$animation_css .= "[data-kirki='" . $ele_id . "'] { visibility: hidden; }";
}
+ foreach ( $this->interaction_library_text_animation_tracker as $ele_id => $devices ) {
+ $css_value = "[data-kirki='" . $ele_id . "'] { visibility: hidden; }";
+ $media_queries = ! empty( $devices ) ? $this->setMediaQuery( $css_value, $devices ) : '';
+ $animation_css .= ! empty( $media_queries ) ? $media_queries : $css_value;
+ }
+
if ( $animation_css ) {
$s .= "<style data='kirki-element-animation-visibility'>";
$s .= $animation_css;
@@ -855,6 +863,8 @@
$s = $style_tag ? "<style id='kirki-variables-" . $key . "'>".$selector."{" : $selector."{";
$view_ports = UserData::get_view_port_list();
+ $text_style_css = "";
+
foreach ($variables['data'] as $key2 => $group) {
if($group['key'] !== $mode_type) {
continue;
@@ -883,13 +893,17 @@
$s .= "$name:" . $variable['value'][$mode] . ";";
break;
case 'text-style':
- $s .= self::buildTextStyleCss( $variable, $mode, $view_ports );
+ $text_style_css .= self::buildTextStyleCss( $variable, $mode, $view_ports );
break;
}
}
}
- $s .= $style_tag ? '}</style>' : '}';
+ if($selector && $selector !== ':root'){
+ $text_style_css = "$selector{$text_style_css}";
+ }
+
+ $s .= $style_tag ? "}$text_style_css</style>" : "}$text_style_css";
self::$printed_variable_tracker[ $k ] = true;
return $s;
@@ -1061,6 +1075,7 @@
$seo_open_graph_image = self::getSeoValue( $post_id, $seo_settings['openGraph']['openGraphImage']['value'] );
$meta_tags .= self::getMeta( 'og:image', $seo_open_graph_image );
+ $meta_tags .= self::getMeta( 'twitter:card', 'summary_large_image' );
$meta_tags .= self::getMeta( 'twitter:image', $seo_open_graph_image );
}
@@ -1271,7 +1286,7 @@
$this->interactions[ $id ] = $this->updateClassListForInteractionFromStyleBlockId( $properties['interactions'], $element );
}
if(isset($properties['interactionLibrary'])) {
- $this->interaction_library[ $id ] = $properties['interactionLibrary'];
+ $this->interaction_library[ $id ] = $this->updateStylesForInteractionLibrary( $properties['interactionLibrary'], $element );
}
if ( isset( $properties['code'], $properties['code']['javascript'] ) ) {
$this->custom_codes .= str_replace( 'KIRKI_TARGET_ELEMENT_ID', $id, $properties['code']['javascript'] );
@@ -1699,6 +1714,18 @@
return $interactionData;
}
+ private function updateStylesForInteractionLibrary( $interactionLibraryData, $element ) {
+ $id = $element['id'];
+ foreach ( $interactionLibraryData as $interactionLibrary ) {
+ if ( isset( $interactionLibrary['type'] ) && $interactionLibrary['type'] === 'textAnimation' ) {
+ $devices = isset( $interactionLibrary['deviceAndClassList']['devices'] ) ? $interactionLibrary['deviceAndClassList']['devices'] : array();
+ $this->interaction_library_text_animation_tracker[ $id ] = $devices;
+ break;
+ }
+ }
+ return $interactionLibraryData;
+ }
+
/**
* Get element related config
*
@@ -1880,6 +1907,7 @@
'generate_element' => array( $this, 'generateSingleElement' ),
'generate_child_element_with_new_id' => array( new HelperFunctions(), 'rec_update_data_id_then_return_new_html' ),
'get_data_and_styles_from_root' => array( new DataHelper(), 'get_data_and_styles_from_root' ),
+ 'get_collection_info' => array( $this, 'get_collection_info' ),
)
);
}
@@ -2026,6 +2054,11 @@
}
$content = apply_filters( 'kirki_dynamic_content', false, $contentInfo );
if ( $content ) {
+ // Plain-text dynamic values (comment fields) are visitor supplied,
+ // so they are never trusted as a URL here.
+ if ( is_string( $content ) && in_array( $dynamic_content['type'] ?? '', Utils::get_plain_text_dynamic_content_types(), true ) ) {
+ return esc_url( html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+ }
return $content;
}
@@ -2432,4 +2465,4 @@
);
return join( ' ', $arr );
}
-}
No newline at end of file
+}
--- a/kirki/includes/Frontend/Preview/Utils.php
+++ b/kirki/includes/Frontend/Preview/Utils.php
@@ -285,6 +285,52 @@
return $args;
}
+ /**
+ * Dynamic content types whose values are plain text by nature and must never
+ * be rendered as live markup.
+ *
+ * Comment fields are the important case: their content is supplied by
+ * (potentially unauthenticated) visitors, so anything stored there is
+ * untrusted. Rich types — `post` (post_content), `reference`, `gallery`,
+ * `menu` — are deliberately excluded, because those are authored by users
+ * with editing capabilities and are expected to emit HTML.
+ *
+ * @return string[]
+ */
+ public static function get_plain_text_dynamic_content_types() {
+ return apply_filters( 'kirki_plain_text_dynamic_content_types', array( 'comment' ) );
+ }
+
+ /**
+ * Escape a resolved dynamic value when its type is plain text by nature.
+ *
+ * The value is decoded first and then escaped, so markup that was stored in
+ * entity-encoded form (`<script>`) is normalised to a single, escaped
+ * representation and displays as the literal text the visitor typed instead
+ * of turning back into a live tag further down the render pipeline.
+ *
+ * @param mixed $content The resolved dynamic value.
+ * @param array $dynamic_content The dynamic content definition.
+ * @return mixed The escaped value, or the value untouched when it is not plain text.
+ */
+ public static function esc_dynamic_text_value( $content, $dynamic_content ) {
+ if ( ! is_string( $content ) || '' === $content ) {
+ return $content;
+ }
+
+ $type = isset( $dynamic_content['type'] ) ? $dynamic_content['type'] : '';
+
+ if ( ! in_array( $type, self::get_plain_text_dynamic_content_types(), true ) ) {
+ return $content;
+ }
+
+ return htmlspecialchars(
+ html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
+ ENT_QUOTES,
+ 'UTF-8'
+ );
+ }
+
public static function getDynamicRichTextValue( $dynamic_content, $options ) {
$html = '';
if ( ! $dynamic_content ) {
@@ -308,7 +354,7 @@
}
$content = apply_filters( 'kirki_dynamic_content', false, $contentInfo );
if ( $content ) {
- return $content;
+ return self::esc_dynamic_text_value( $content, $dynamic_content );
}
// fix warning
--- a/kirki/includes/Frontend/TheFrontend.php
+++ b/kirki/includes/Frontend/TheFrontend.php
@@ -134,7 +134,6 @@
$content = $this->kirki_type_html_data['content'];
}
// Decode HTML entities. Example: [gravityform id="1"] → [gravityform id="1"]
- $content = html_entity_decode( $content, ENT_NOQUOTES | ENT_HTML5, 'UTF-8' );
// Run shortcode manually
$content = do_shortcode( $content );
--- a/kirki/includes/HelperFunctions.php
+++ b/kirki/includes/HelperFunctions.php
@@ -1034,7 +1034,8 @@
// For now, only string is supported!
if (is_string($meta)) {
- $content = $meta;
+ // Post meta is arbitrary, low-privilege-authored data: always escape.
+ $content = self::escape_post_meta_value($meta);
}
}
@@ -1374,9 +1375,53 @@
$s .= $html;
$s .= $preview->getScriptTag($should_take_app_script);
+
+ $s = self::decode_entities_without_creating_markup($s);
return $s;
}
+ /**
+ * Decode HTML entities in already-rendered page markup without ever turning
+ * escaped text back into live tags.
+ *
+ * The rendered document mixes trusted markup (elements the builder emitted,
+ * including admin authored custom code) with escaped text nodes. A blanket
+ * html_entity_decode() over that mix undoes the escaping applied while
+ * rendering, so `<script>` stored in any text value — a visitor's
+ * comment, for instance — becomes an executable `<script>` tag.
+ *
+ * Angle-bracket entities are therefore held back while everything else is
+ * decoded as before, then restored verbatim. Entities such as `&`,
+ * ` ` and named HTML5 entities keep decoding exactly as they used to;
+ * markup emitted by the renderer is unaffected because it contains literal
+ * `<`/`>`, not entities.
+ *
+ * @param string $content Rendered page markup.
+ * @return string
+ */
+ private static function decode_entities_without_creating_markup( $content ) {
+ if ( ! is_string( $content ) || '' === $content ) {
+ return $content;
+ }
+
+ $held = array();
+
+ // `<` `>` and their numeric/hex forms, in any zero-padded spelling.
+ $content = preg_replace_callback(
+ '/&(?:lt|gt|#0*(?:60|62)|#[xX]0*3[ceCE]);/',
+ function ( $matches ) use ( &$held ) {
+ $key = "x02kirki-entity-" . count( $held ) . "x03";
+ $held[ $key ] = $matches[0];
+ return $key;
+ },
+ $content
+ );
+
+ $content = html_entity_decode( $content, ENT_NOQUOTES | ENT_HTML5, 'UTF-8' );
+
+ return strtr( $content, $held );
+ }
+
private static function collect_search_related_collection_ids($data)
{
$result = array();
@@ -3527,6 +3572,26 @@
}
}
+ /**
+ * Escape a post meta value for safe output.
+ *
+ * The front-end content pipeline (TheFrontend::replace_content) applies a
+ * single html_entity_decode() pass after core has processed shortcodes,
+ * which would undo a plain esc_html(). Re-encoding the ampersands (with
+ * double_encode enabled) yields a single-escaped value in the final output
+ * while still neutralizing markup authored by low-privileged users.
+ *
+ * @param mixed $value The raw post meta value.
+ * @return string Escaped value.
+ */
+ public static function escape_post_meta_value($value)
+ {
+ if (!is_scalar($value)) {
+ return '';
+ }
+
+ return htmlspecialchars(esc_html((string) $value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true);
+ }
/**
* Is Pro user checking function.
@@ -4701,4 +4766,38 @@
}
return $mode;
}
+
+ /**
+ * Whether the target url is a safe, externally reachable http(s) URL.
+ *
+ * Rejects loopback, private, link-local and cloud-metadata addresses so a
+ * planted form config cannot be used to probe the server's own network.
+ *
+ * @param string $url The URL.
+ * @return bool
+ */
+ public static function is_safe_url($url) {
+ $scheme = wp_parse_url($url, PHP_URL_SCHEME);
+ $host = wp_parse_url($url, PHP_URL_HOST);
+
+ if (!is_string($scheme) || !in_array(strtolower($scheme), array('http', 'https'), true)) {
+ return false;
+ }
+
+ if (!is_string($host) || '' === $host) {
+ return false;
+ }
+
+ if (filter_var($host, FILTER_VALIDATE_IP)) {
+ return (bool) filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+ }
+
+ $ip = gethostbyname($host);
+
+ if (!filter_var($ip, FILTER_VALIDATE_IP) || $ip === $host) {
+ return false;
+ }
+
+ return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+ }
}
--- a/kirki/includes/Manager/PluginShortcode.php
+++ b/kirki/includes/Manager/PluginShortcode.php
@@ -172,7 +172,10 @@
if ( is_singular() ) {
global $post;
$meta_value = get_post_meta( $post->ID, $data, true );
- return $meta_value;
+ if ( is_string( $meta_value ) ) {
+ // Post meta is arbitrary, low-privilege-authored data: always escape.
+ return KirkiHelperFunctions::escape_post_meta_value( $meta_value );
+ }
}
return '';
}
--- a/kirki/includes/Manager/TemplateRedirection.php
+++ b/kirki/includes/Manager/TemplateRedirection.php
@@ -251,7 +251,7 @@
$kirki_data = HelperFunctions::is_kirki_type_data($template['id'], $staging_version);
if ($kirki_data) {
-
+ $options['kirki_template_id'] = $template['id'];
$params = array(
'blocks' => $kirki_data['blocks'],
'style_blocks' => $kirki_data['styles'],
--- a/kirki/kirki.php
+++ b/kirki/kirki.php
@@ -7,7 +7,7 @@
* Plugin Name: Kirki
* Plugin URI: https://kirki.com
* Description: Kirki is an all-in-one no-code builder that empowers users to build professional-grade WordPress sites without writing any code. It’s a promising glimpse into the future of website development.
- * Version: 6.2.0
+ * Version: 6.2.1
* Author: Kirki
* Author URI: https://kirki.com
* License: GPLv2 or later
@@ -26,7 +26,7 @@
// Define KIRKI_VERSION early to prevent bundled Kirki versions from loading.
if ( ! defined( 'KIRKI_VERSION' ) ) {
- define( 'KIRKI_VERSION', '6.2.0' );
+ define( 'KIRKI_VERSION', '6.2.1' );
}
require_once plugin_dir_path( __FILE__ ) . 'config.php';
--- a/kirki/libraries/framework/Validation/RuleFactory.php
+++ b/kirki/libraries/framework/Validation/RuleFactory.php
@@ -137,7 +137,7 @@
if (!$instance->has_constraint($name)) {
continue;
}
- if (!empty($arguments)) {
+ if ($arguments !== null) {
$instance->{$name}($arguments);
} else {
$instance->{$name}();
@@ -252,7 +252,7 @@
return null;
}
$arguments = array_last(explode(':', $rule, 2));
- if (empty($arguments)) {
+ if ($arguments === null || $arguments === '') {
return null;
}
if (str_contains($arguments, ',')) {
--- a/kirki/vendor/composer/installed.php
+++ b/kirki/vendor/composer/installed.php
@@ -29,9 +29,9 @@
'dev_requirement' => false,
),
'themeum/framework' => array(
- 'pretty_version' => '2.1.6',
- 'version' => '2.1.6.0',
- 'reference' => '426d51a81d5b820863f4cfbe5e5621fd736cb082',
+ 'pretty_version' => '2.1.7',
+ 'version' => '2.1.7.0',
+ 'reference' => 'baa13d110ab722ae934542635ade9c398e42e109',
'type' => 'library',
'install_path' => __DIR__ . '/../themeum/framework',
'aliases' => array(),