Published : July 1, 2026

CVE-2026-12122: Kirki <= 6.0.11 Missing Authorization to Unauthenticated Sensitive Information Exposure via kirki_post_apis_nopriv AJAX Action PoC, Patch Analysis & Rule

Plugin kirki
Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 6.0.11
Patched Version 6.0.12
Disclosed June 30, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-12122:

This vulnerability exposes sensitive information from Kirki WordPress plugin versions up to 6.0.11. The flaw is a missing authorization check in the `get_single_symbol` endpoint, which is accessible to unauthenticated attackers. By supplying a sequential WordPress post ID, an attacker can extract full builder metadata and rendered HTML from any `kirki_symbol` post, including unpublished drafts. The vulnerability carries a CVSS score of 5.3 (Medium) and is classified under CWE-862 (Missing Authorization).

The root cause originates in the `kirki/includes/Ajax/Symbol.php` file, specifically in the `get_pre_built_html_using_url()` function. This function directly accepts a user-supplied `elementUrl` parameter via GET request and passes it to `HelperFunctions::http_get()` without any host validation, IP filtering, or URL sanitization. An attacker can craft a URL pointing to a private or loopback IP (e.g., 127.0.0.1, 10.0.0.1, 169.254.169.254) and trigger server-side requests that can access internal resources, including the `get_single_symbol` endpoint which also lacked authorization checks. The vulnerable code path does not require authentication or any capability check, allowing unauthenticated users to enumerate WordPress post IDs and extract the contents of `kirki_symbol` custom post types.

Exploitation requires sending a GET request to `/wp-admin/admin-ajax.php?action=kirki_dynamic_setting_action&endpoint=get-pre-built-html&elementUrl=http://127.0.0.1/wp-json/kirki/v1/symbol?id=N`. Alternatively, an attacker can directly invoke the REST API endpoint that serves `kirki_symbol` posts. By iterating through sequential post IDs (1, 2, 3, …), the attacker can retrieve the full builder metadata, rendered HTML, and any sensitive data (such as API keys, database credentials, or private content) stored within those posts. The lack of a nonce or capability check on the `get_single_symbol` endpoint makes this trivial to automate.

The patch introduces comprehensive security controls. In `Symbol.php`, the `get_pre_built_html_using_url()` function now validates the host against the WordPress site’s home URL, resolves the IP and blocks private/loopback ranges (RFC 1918, IPv4-mapped, link-local), and reconstructs the URL from parsed components to prevent SSRF. Additionally, all affected endpoints in `Ajax.php` now require `FULL_ACCESS` level (`HelperFunctions::has_access(KIRKI_ACCESS_LEVELS[‘FULL_ACCESS’])`) before executing sensitive operations. The `CompLibFormHandler.php` adds permission callbacks to all REST API endpoints, replacing the formerly permissive `get_item_permissions_check` with specific guest or logged-in checks.

Exploitation of this vulnerability allows an unauthenticated attacker to extract the complete contents of any `kirki_symbol` custom post type, including draft and unpublished posts. This can expose sensitive information such as embedded API keys, database connection strings, site configuration data, and any proprietary builder metadata. The data can be used to pivot to further attacks, including privilege escalation or lateral movement within the WordPress ecosystem. Given that `kirki_symbol` posts may contain rich HTML and user-provided content, the impact is limited to confidentiality breaches, but the severity is heightened by the fact that no authentication is required and the attack can be easily automated.

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
@@ -17,18 +17,18 @@
 	protected $namespace = KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '/v1';

 	public function __construct() {
-		$this->init_rest_api_endpoint( 'kirki-login', WP_REST_Server::CREATABLE, array( $this, 'handle_login' ) );
-		$this->init_rest_api_endpoint( 'kirki-register', WP_REST_Server::CREATABLE, array( $this, 'handle_register' ) );
-		$this->init_rest_api_endpoint( 'kirki-forgot-password', WP_REST_Server::CREATABLE, array( $this, 'handle_forgot_password' ) );
-		$this->init_rest_api_endpoint( 'kirki-change-password', WP_REST_Server::CREATABLE, array( $this, 'handle_change_password' ) );
-		$this->init_rest_api_endpoint( 'kirki-retrieve-username', WP_REST_Server::CREATABLE, array( $this, 'handle_retrieve_username' ) );
-		$this->init_rest_api_endpoint( 'kirki-comment', WP_REST_Server::CREATABLE, array( $this, 'handle_post_comment' ) );
+		$this->init_rest_api_endpoint( 'kirki-login', WP_REST_Server::CREATABLE, array( $this, 'handle_login' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-register', WP_REST_Server::CREATABLE, array( $this, 'handle_register' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-forgot-password', WP_REST_Server::CREATABLE, array( $this, 'handle_forgot_password' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-change-password', WP_REST_Server::CREATABLE, array( $this, 'handle_change_password' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-retrieve-username', WP_REST_Server::CREATABLE, array( $this, 'handle_retrieve_username' ), array( $this, 'guest_permissions_check' ) );
+		$this->init_rest_api_endpoint( 'kirki-comment', WP_REST_Server::CREATABLE, array( $this, 'handle_post_comment' ), array( $this, 'comment_permissions_check' ) );
 	}

-	public function init_rest_api_endpoint( $endpoint, $methods, $callback ) {
+	public function init_rest_api_endpoint( $endpoint, $methods, $callback, $permission_callback = null ) {
 		add_action(
 			'rest_api_init',
-			function () use ( $endpoint, $methods, $callback ) {
+			function () use ( $endpoint, $methods, $callback, $permission_callback ) {
 				register_rest_route(
 					$this->namespace,
 					'/' . $endpoint,
@@ -36,7 +36,7 @@
 						array(
 							'methods'             => $methods,
 							'callback'            => $callback,
-							'permission_callback' => array( $this, 'get_item_permissions_check' ),
+							'permission_callback' => $permission_callback ? $permission_callback : array( $this, 'get_item_permissions_check' ),
 							'args'                => $this->get_endpoint_args_for_item_schema( $methods ),
 						),
 						'schema' => array( $this, 'get_item_schema' ),
@@ -50,6 +50,21 @@
 		return true;
 	}

+	public function guest_permissions_check( $request ) {
+		return true;
+	}
+
+	public function comment_permissions_check( $request ) {
+		if ( ! is_user_logged_in() && get_option( 'default_comment_status' ) !== 'open' ) {
+        return new WP_Error(
+            'rest_forbidden',
+            __( 'You must be logged in to post comments.' ),
+            array( 'status' => 401 )
+        );
+    }
+    return true;
+	}
+
 	private function wp_unique_username( $username, $suffix = 1 ) {
 		$original_username = $username;
 		while ( username_exists( $username ) ) {
@@ -58,29 +73,146 @@
 		return $username;
 	}

+	private function validate_meta_field( $field_name ) {
+		$allowed_meta_fields = apply_filters( 'kirki_allowed_registration_meta_fields', array(
+			'first_name',
+			'last_name',
+			'phone',
+			'company',
+			'address',
+			'city',
+			'state',
+			'country',
+			'zip',
+		) );
+
+		if ( ! in_array( $field_name, $allowed_meta_fields, true ) ) {
+			return false;
+		}
+
+		if ( preg_match( '/[^a-z0-9_-]/i', $field_name ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Verify that the submitted emailSubject + emailBody were signed by the server
+	 * at page-render time and have not been tampered with.
+	 *
+	 * IMPORTANT: $body_raw must be the raw JSON string as received from the request —
+	 * never a re-encoded array. Re-encoding can produce different output than the
+	 * original wp_json_encode() call, breaking the HMAC comparison.
+	 *
+	 * @param string $subject   The email subject string.
+	 * @param string $body_raw  The raw emailBody JSON string from the request.
+	 * @param string $signature The HMAC signature to verify against.
+	 * @return bool
+	*/
+	private function verify_email_template_signature( $subject, $body_raw, $signature ) {
+		if ( empty( $signature ) ) {
+				return false;
+		}
+
+		// Use the raw string directly — same as what was signed in ElementGenerator.
+		// Do NOT json_decode then re-encode here.
+		$payload  = $subject . '|' . $body_raw;
+		$secret   = AUTH_KEY . AUTH_SALT;
+		$expected = hash_hmac( 'sha256', $payload, $secret );
+
+		return hash_equals( $expected, $signature );
+	}
+
+	/**
+	 * Build the email body from a verified emailBody definition.
+	 * Chip values are resolved from a fixed server-controlled map.
+	 *
+	 * @param array $email_body_array
+	 * @param array $chip_data
+	 * @return string
+	 */
+	private function build_email_body( array $email_body_array, array $chip_data ) {
+		$email_body = '';
+		foreach ( $email_body_array as $body_data ) {
+				if ( ! isset( $body_data['type'], $body_data['value'] ) ) {
+						continue;
+				}
+				if ( $body_data['type'] === 'text' ) {
+						$email_body .= $body_data['value'];
+				} elseif ( $body_data['type'] === 'chip' && isset( $chip_data[ $body_data['value'] ] ) ) {
+						$email_body .= $chip_data[ $body_data['value'] ];
+				}
+		}
+		return $email_body;
+	}
+
 	public function handle_post_comment( $request ) {
-		$form_data     = $request->get_body_params();
-		$transiet_name = $this->validate_nonce( 'kirki-comment' );
+    $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'] ) : '';
+    $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();
+    $user           = $user_id ? get_user_by( 'ID', $user_id ) : null;

-		$name           = isset( $form_data['name'] ) ? sanitize_text_field( $form_data['name'] ) : '';
-		$email          = isset( $form_data['email'] ) ? sanitize_email( $form_data['email'] ) : '';
-		$comment        = isset( $form_data['comment'] ) ? sanitize_text_field( $form_data['comment'] ) : '';
-		$post_id        = isset( $form_data['post_id'] ) ? sanitize_text_field( $form_data['post_id'] ) : 0;
-		$comment_parent = isset( $form_data['comment_parent'] ) ? sanitize_text_field( $form_data['comment_parent'] ) : 0;
-	  $date    = gmdate( 'Y-m-d H:i:s' );
-		$user_id        = get_current_user_id();
-		$user           = get_user_by( 'ID', $user_id );
-		if ( $user ) {
+    // Resolve author identity.
+    if ( $user ) {
 			$name  = $user->get( 'display_name' );
 			$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'] )  : '';
+			$email = isset( $form_data['email'] ) ? sanitize_email( $form_data['email'] )       : '';
+
+			if ( empty( $name ) || empty( $email ) || ! is_email( $email ) ) {
+				return new WP_REST_Response(
+					array( 'message' => 'Name and a valid email address are required.' ),
+					400
+				);
+			}
+    }
+
+    $existing_comment_id = isset( $form_data['comment_id'] ) ? absint( $form_data['comment_id'] ) : 0;
+    $is_edit             = $existing_comment_id !== 0;
+    $collection_type     = isset( $form_data['collection_type'] ) ? sanitize_text_field( $form_data['collection_type'] ) : '';
+
+    // -----------------------------------------------------------------------
+    // EDIT PATH
+    // -----------------------------------------------------------------------
+    if ( $is_edit ) {
+			// FIX 1: Editing always requires an authenticated session.
+			if ( ! is_user_logged_in() ) {
+				return new WP_REST_Response(
+						array( 'message' => 'You must be logged in to edit a comment.' ),
+						401
+				);
+			}
+
+			$existing_comment = get_comment( $existing_comment_id );
+
+			if ( ! $existing_comment ) {
+				return new WP_REST_Response(
+						array( 'message' => 'Comment not found.' ),
+						404
+				);
+			}

-		$existing_comment_id = isset( $form_data['comment_id'] ) ? sanitize_text_field( $form_data['comment_id'] ) : 0;
-		$is_edit             = 0 == $existing_comment_id ? false : true;
-		$collection_type     = isset( $form_data['collection_type'] ) ? sanitize_text_field( $form_data['collection_type'] ) : '';
+			// FIX 1 (cont.): strict ownership — user_id 0 must never match.
+			$is_owner    = ( $user_id !== 0 && (int) $existing_comment->user_id === $user_id );
+			$is_moderator = current_user_can( 'moderate_comments' );
+
+			if ( ! $is_owner && ! $is_moderator ) {
+				return new WP_REST_Response(
+					array( 'message' => 'You are not authorized to edit this comment.' ),
+					403
+				);
+			}

-		global $wpdb;
-		if ( $is_edit ) {
+			$date = gmdate( 'Y-m-d H:i:s' );
+
+			global $wpdb;
 			$wpdb->update(
 				$wpdb->comments,
 				array(
@@ -90,6 +222,7 @@
 				),
 				array( 'comment_ID' => $existing_comment_id )
 			);
+
 			apply_filters(
 				'kirki_comment_added-' . $collection_type,
 				array(
@@ -98,44 +231,60 @@
 					'form_data'  => $form_data,
 				)
 			);
-		} else {
-			$comment_data = array(
-				'comment_post_ID'      => $post_id,
-				'user_id'              => $user_id,
-				'comment_author'       => $name,
-				'comment_author_email' => $email,
-				'comment_content'      => $comment,
-				'comment_parent'       => $comment_parent,
-				'comment_approved'     => 1,
-				'comment_date'         => $date,
-				'comment_date_gmt'     => get_gmt_from_date( $date ),
-			);
-			$comment_data = apply_filters( 'kirki_comment-' . $collection_type, $comment_data );
-			$wpdb->insert( $wpdb->comments, $comment_data );
-			$comment_id = (int) $wpdb->insert_id;
-			apply_filters(
-				'kirki_comment_added-' . $collection_type,
-				array(
+
+			delete_transient( $transient_name );
+			return new WP_REST_Response( array( 'message' => 'Comment updated.' ), 200 );
+    }
+
+    // -----------------------------------------------------------------------
+    // INSERT PATH
+    // -----------------------------------------------------------------------
+
+    // FIX 2: Build the comment array without hardcoding comment_approved=1,
+    // then route through wp_new_comment() so WordPress moderation, spam
+    // filters (Akismet, etc.), and flood checks all apply normally.
+    $comment_data = array(
+			'comment_post_ID'      => $post_id,
+			'user_id'              => $user_id,
+			'comment_author'       => $name,
+			'comment_author_email' => $email,
+			'comment_author_IP'    => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
+			'comment_agent'        => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
+			'comment_content'      => $comment,
+			'comment_parent'       => $comment_parent,
+    );
+
+    $comment_data = apply_filters( 'kirki_comment-' . $collection_type, $comment_data );
+
+    // wp_new_comment() runs duplicate/flood/spam checks, fires hooks, and
+    // respects the site's moderation settings.
+    $comment_id = wp_new_comment( $comment_data, true );  // true = return WP_Error on failure
+
+    if ( is_wp_error( $comment_id ) ) {
+			return new WP_REST_Response(
+					array( 'message' => $comment_id->get_error_message() ),
+					400
+			);
+    }
+
+    if ( ! $comment_id ) {
+			return new WP_REST_Response(
+					array( 'message' => 'Failed to add comment.' ),
+					400
+			);
+    }
+
+    apply_filters(
+			'kirki_comment_added-' . $collection_type,
+			array(
 					'comment_ID' => $comment_id,
 					'user_id'    => $user_id,
 					'form_data'  => $form_data,
-				)
-			);
-		}
+			)
+    );

-		// Check if the comment was added successfully.
-		if ( $comment_id ) {
-			$response = array(
-				'message' => 'Comment Added',
-			);
-			delete_transient( $transiet_name );
-			return new WP_REST_Response( $response, 200 );
-		} else {
-			$response = array(
-				'message' => 'Invalid form data',
-			);
-			return new WP_REST_Response( $response, 400 );
-		}
+    delete_transient( $transient_name );
+    return new WP_REST_Response( array( 'message' => 'Comment added.' ), 200 );
 	}


@@ -154,9 +303,9 @@
 				$username = $user->get( 'user_login' );
 			} else {
 				$response = array(
-					'message' => 'User not found',
+					'message' => 'Invalid username or password',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 401 );
 			}
 		}

@@ -174,9 +323,9 @@

 			if ( is_wp_error( $user ) ) {
 				$response = array(
-					'message' => $user->errors[ array_key_first( $user->errors ) ],
+					'message' => 'Invalid username or password',
 				);
-				return new WP_REST_Response( $response, 500 );
+				return new WP_REST_Response( $response, 401 );
 			}
 			$response = array(
 				'message' => 'User logged in',
@@ -227,7 +376,9 @@

 		foreach ( $form_data as $name => $value ) {
 			if ( $name !== 'username' && $name !== 'email' && $name !== 'password' && $name !== 'confirm_password' ) {
-				$user_data['meta_input'][ KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '_' . $name ] = $value;
+				if ( $this->validate_meta_field( $name ) ) {
+					$user_data['meta_input'][ KIRKI_COMPONENT_LIBRARY_APP_PREFIX . '_' . $name ] = sanitize_text_field( $value );
+				}
 			}
 		}

@@ -270,7 +421,7 @@
 			$user = get_user_by( 'email', $email );

 			if ( ! $user ) {
-				return new WP_REST_Response( array( 'message' => 'User not found' ), 404 );
+				return new WP_REST_Response( array( 'message' => 'If an account exists with this email, you will receive a password reset link.' ), 200 );
 			}

 			$username = $user->get( 'user_login' );
@@ -285,17 +436,17 @@

 			if ( ! $user ) {
 				$response = array(
-					'message' => 'User not found',
+					'message' => 'If an account exists with this information, you will receive a password reset link.',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 200 );
 			}

 			$user_email = $user->get( 'user_email' );
 			if($email !== $user_email) {
 				$response = array(
-					'message' => 'Invalid email address',
+					'message' => 'If an account exists with this information, you will receive a password reset link.',
 				);
-				return new WP_REST_Response( $response, 404 );
+				return new WP_REST_Response( $response, 200 );
 			}
 			$email = $user_email;

@@ -319,27 +470,29 @@
 				'reset_link'  => "$url?action=rp&key=$key&login=" . rawurlencode( $username ),
 			);

-			$email_subject = isset( $form_data['emailSubject'] ) ? sanitize_text_field( $form_data['emailSubject'] ) : '';
-			$email_body    = '';
+			$email_subject   = isset( $form_data['emailSubject'] ) ? $form_data['emailSubject'] : '';
+			$email_body_raw  = isset( $form_data['emailBody'] ) ? $form_data['emailBody'] : '[]';
+			$email_signature = isset( $form_data['emailSignature'] ) ? $form_data['emailSignature'] : '';

-			if ( isset( $form_data['emailBody'] ) ) {
-				$email_body_array = json_decode( $form_data['emailBody'], true );
-				foreach ( $email_body_array as $key => $body_data ) {
-					if ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'text' ) {
-						$email_body .= $body_data['value'];
-					} elseif ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'chip' ) {
-						$email_body .= $chip_data[ $body_data['value'] ];
-					}
-				}
+			if ( ! $this->verify_email_template_signature( $email_subject, $email_body_raw, $email_signature ) ) {
+				wp_send_json_error( array( 'message' => 'Invalid request' ), 400 );
+				exit;
 			}

+			$email_body_array = json_decode( $email_body_raw, true );
+			if ( ! is_array( $email_body_array ) ) {
+				$email_body_array = array();
+			}
+
+			$email_body = $this->build_email_body( $email_body_array, $chip_data );
+
 			$email_body = nl2br( $email_body );

 			$headers = array( 'Content-Type: text/html; charset=UTF-8' );

 			// Send custom email.
 			apply_filters( 'kirki_element_smtp', '' );
-			$sent = wp_mail( $email, $email_subject, $email_body, $headers );
+			$sent = wp_mail( $email, sanitize_text_field( $email_subject ), $email_body, $headers );

 			if ( $sent ) {
 				$response = array(
@@ -407,7 +560,7 @@
 		$user = get_user_by( 'email', $email );

 		if ( ! $user ) {
-			wp_send_json_error( array( 'message' => 'No user found with that email address.' ), 404 );
+			wp_send_json_success( array( 'message' => 'If an account exists with this email, you will receive your username.' ) );
 			exit;
 		}

@@ -419,26 +572,28 @@
 			'sitename'    => get_bloginfo( 'name' ),
 		);

-		$email_subject = isset( $form_data['emailSubject'] ) ? sanitize_text_field( $form_data['emailSubject'] ) : '';
-		$email_body    = '';
+		$email_subject   = isset( $form_data['emailSubject'] ) ? $form_data['emailSubject'] : '';
+		$email_body_raw  = isset( $form_data['emailBody'] ) ? $form_data['emailBody'] : '[]';
+		$email_signature = isset( $form_data['emailSignature'] ) ? $form_data['emailSignature'] : '';

-		if ( isset( $form_data['emailBody'] ) ) {
-			$email_body = json_decode( $form_data['emailBody'], true );
-			foreach ( $email_body as $key => $body_data ) {
-				if ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'text' ) {
-					$email_body = $email_body . $body_data['value'];
-				} elseif ( isset( $body_data['type'] ) && isset( $body_data['value'] ) && $body_data['type'] === 'chip' ) {
-					$email_body = $email_body . $chip_data[ $body_data['value'] ];
-				}
-			}
+		if ( ! $this->verify_email_template_signature( $email_subject, $email_body_raw, $email_signature ) ) {
+			wp_send_json_error( array( 'message' => 'Invalid request' ), 400 );
+			exit;
+		}
+
+		$email_body_array = json_decode( $email_body_raw, true );
+		if ( ! is_array( $email_body_array ) ) {
+			$email_body_array = array();
 		}

+		$email_body = $this->build_email_body( $email_body_array, $chip_data );
+
 		$email_body = nl2br( $email_body );

 		$headers = array( 'Content-Type: text/html; charset=UTF-8' );

 		apply_filters( 'kirki_element_smtp', '' );
-		$email_sent = wp_mail( $email, $email_subject, $email_body, $headers );
+		$email_sent = wp_mail( $email, sanitize_text_field( $email_subject ), $email_body, $headers );

 		if ( ! $email_sent ) {
 			wp_send_json_error( array( 'message' => 'Failed to send email. Please try again later.' ), 500 );
@@ -450,6 +605,13 @@
 		exit;
 	}

+	/**
+	 * Validate the nonce from the request header and return true on success.
+	 * Exits with an error response on failure.
+	 *
+	 * @param string $element_name
+	 * @return true
+	 */
 	public function validate_nonce( $element_name ) {
 		$nonce = isset( $_SERVER['HTTP_X_WP_ELEMENT_NONCE'] )
 		? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_ELEMENT_NONCE'] ) )
--- a/kirki/ComponentLibrary/controller/ElementGenerator.php
+++ b/kirki/ComponentLibrary/controller/ElementGenerator.php
@@ -40,22 +40,57 @@


 	private function add_element_config() {
-		$id = $this->element['id'];
-		if (
-		$this->element['name'] === 'kirki-login' || $this->element['name'] === 'kirki-register' ||
-		$this->element['name'] === 'kirki-forgot-password' || $this->element['name'] === 'kirki-change-password' ||
-		$this->element['name'] === 'kirki-retrieve-username' || $this->element['name'] === 'kirki-comment'
-		) {
-			$nonce                            = $this->add_nonce_to_element( $this->element );
-			$this->component_lib_forms[ $id ] = array_merge(
-				$this->properties['attributes'],
-				$this->setting,
-				array(
-					'name'  => $this->element['name'],
-					'nonce' => $nonce,
-				)
-			);
-		}
+    $id = $this->element['id'];
+    if (
+        $this->element['name'] === 'kirki-login' || $this->element['name'] === 'kirki-register' ||
+        $this->element['name'] === 'kirki-forgot-password' || $this->element['name'] === 'kirki-change-password' ||
+        $this->element['name'] === 'kirki-retrieve-username' || $this->element['name'] === 'kirki-comment'
+    ) {
+        $nonce  = $this->add_nonce_to_element( $this->element );
+        $config = array_merge(
+					$this->properties['attributes'],
+					$this->setting,
+					array(
+						'name'  => $this->element['name'],
+						'nonce' => $nonce,
+					)
+        );
+
+        // SECURITY FIX: Sign the email template so the REST handler can verify
+        // it was not tampered with — without any extra DB queries.
+        // Always generate a signature for these element types, even when
+        // settings are empty, so the client always has a value to send.
+        if ( in_array( $this->element['name'], array( 'kirki-forgot-password', 'kirki-retrieve-username' ), true ) ) {
+					if ( ! isset( $config['emailSubject'] ) ) {
+							$config['emailSubject'] = '';
+					}
+					if ( ! isset( $config['emailBody'] ) ) {
+							$config['emailBody'] = array();
+					}
+					$config['emailSignature'] = $this->sign_email_template(
+							$config['emailSubject'],
+							$config['emailBody']
+					);
+        }
+
+        $this->component_lib_forms[ $id ] = $config;
+    }
+	}
+
+	/**
+	 * Produce an HMAC signature over the admin-configured email template.
+	 * Uses WordPress AUTH_KEY + AUTH_SALT so it is server-secret and
+	 * never reproducible by an external attacker.
+	 *
+	 * @param string       $subject
+	 * @param array|string $body
+	 * @return string  Hex HMAC-SHA256 signature.
+	 */
+	private function sign_email_template( $subject, $body ) {
+    $body_string = is_array( $body ) ? wp_json_encode( $body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : (string) $body;
+    $payload     = $subject . '|' . $body_string;
+    $secret      = AUTH_KEY . AUTH_SALT;
+    return hash_hmac( 'sha256', $payload, $secret );
 	}

 	public function generate_common_element( $hide = false, $children_html = false ) {
--- a/kirki/includes/Ajax.php
+++ b/kirki/includes/Ajax.php
@@ -337,6 +337,9 @@
 		}

 		if ( $endpoint === 'get-wp-single-post' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			$post_id = (int) HelperFunctions::sanitize_text( isset( $_GET['post_id'] ) ? $_GET['post_id'] : null );
 			$post    = get_post( $post_id );

@@ -424,15 +427,18 @@
 			Symbol::fetch_list( false, true );
 		}

-        if ($endpoint === 'get-page-custom-section') {
-            $type = HelperFunctions::sanitize_text(isset($_GET['type']) ? $_GET['type'] : '');
-            wp_send_json(HelperFunctions::get_page_custom_section($type, true));
-        }
+		if ($endpoint === 'get-page-custom-section') {
+			$type = HelperFunctions::sanitize_text(isset($_GET['type']) ? $_GET['type'] : '');
+			wp_send_json(HelperFunctions::get_page_custom_section($type, true));
+		}

 		/**
 		 * GET Single prebuilt html API
 		 */
 		if ( $endpoint === 'get-pre-built-html' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			Symbol::get_pre_built_html_using_url();
 		}

@@ -549,6 +555,9 @@
 		 * AUTHOR LIST
 		 */
 		if ( 'get-authors' === $endpoint ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_author_list();
 		}

@@ -557,6 +566,9 @@
 		 */

 		if ( 'get-roles' === $endpoint ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_role_list();
 		}

@@ -566,6 +578,9 @@
 		if (
 			'get-users' === $endpoint
 		) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WordpressData::get_user_list();
 		}

@@ -584,6 +599,9 @@
 		}

 		if ( $endpoint === 'get-common-data' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			WpAdmin::get_common_data();
 		}

@@ -755,6 +773,9 @@
 		// From manipulation from admin dashboard.

 		if ( $endpoint === 'get-editor-read-only-access-data' ) {
+			if ( ! HelperFunctions::has_access( KIRKI_ACCESS_LEVELS['FULL_ACCESS'] ) ) {
+				wp_send_json_error( 'Not authorized', 401 );
+			}
 			Page::get_editor_read_only_access_data();
 		}
 	}
--- a/kirki/includes/Ajax/Symbol.php
+++ b/kirki/includes/Ajax/Symbol.php
@@ -349,12 +349,43 @@
 	}

 	public static function get_pre_built_html_using_url() {
-		$url     = isset( $_GET['elementUrl'] ) ? $_GET['elementUrl'] : null;
-		$new_url = sanitize_url( $url );
+		 $raw_url = isset( $_GET['elementUrl'] ) ? wp_unslash( $_GET['elementUrl'] ) : '';

-		$data = HelperFunctions::http_get( $new_url, array( 'sslverify' => false ) );
+    if ( empty( $raw_url ) ) {
+        wp_send_json_error( 'Missing elementUrl', 400 );
+    }
+		$allowed_host = wp_parse_url( home_url(), PHP_URL_HOST );
+    $requested    = wp_parse_url( $raw_url );
+
+    if (
+			! $requested ||
+			empty( $requested['host'] ) ||
+			strtolower( $requested['host'] ) !== strtolower( $allowed_host )
+    ) {
+        wp_send_json_error( 'URL not permitted', 403 );
+    }
+
+		$resolved_ip = gethostbyname( $requested['host'] );
+
+		if ( self::is_private_or_loopback_ip( $resolved_ip ) ) {
+      wp_send_json_error( 'URL not permitted', 403 );
+    }
+
+		$safe_url = self::rebuild_url( $requested );
+
+		$response = HelperFunctions::http_get( $safe_url, array(
+			'timeout'   => 30,
+			'sslverify' => false,
+			'redirection' => 0,
+    ));
+
+		if ( is_wp_error( $response ) ) {
+      wp_send_json_error( 'Request failed', 502 );
+    }
+
+		$body = wp_remote_retrieve_body( $response );
+    $data = json_decode( $body, true );

-		$data = json_decode( $data, true );

 		$params = array(
 			'blocks'                 => $data['blocks'],
@@ -371,4 +402,34 @@

 		wp_send_json_success( $html );
 	}
+
+	/**
+ 	* Returns true for any IP that should never be reached from a server-side fetch.
+ 	* Covers loopback, RFC 1918, link-local (APIPA / cloud metadata), and IPv6 equivalents.
+ 	*/
+	private static function is_private_or_loopback_ip( string $ip ): bool {
+		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+			return true;
+		}
+
+		return ! filter_var(
+			$ip,
+			FILTER_VALIDATE_IP,
+			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+		);
+	}
+
+
+	/**
+	 * Reconstructs a URL from wp_parse_url parts so the raw user string
+	 * is never passed directly to the HTTP client.
+	 */
+	private static function rebuild_url( array $parts ): string {
+		$scheme = isset( $parts['scheme'] ) && strtolower( $parts['scheme'] ) === 'https' ? 'https' : 'https';
+		$host   = $parts['host'];
+		$path   = isset( $parts['path'] ) ? $parts['path'] : '/';
+		$query  = isset( $parts['query'] ) ? '?' . $parts['query'] : '';
+
+		return "{$scheme}://{$host}{$path}{$query}";
+	}
 }
--- a/kirki/includes/Frontend/Preview/Preview.php
+++ b/kirki/includes/Frontend/Preview/Preview.php
@@ -1936,7 +1936,9 @@
 				$content_count = count( $content );
 				for ( $i = 0; $i < $content_count; $i++ ) {
 					if ( is_array( $content[ $i ] ) ) {
-						$html .= $this->recGenHTML( $content[ $i ]['id'], $options );
+						if( isset($content[ $i ]['id']) ){
+							$html .= $this->recGenHTML( $content[ $i ]['id'], $options );
+						}
 					} else {
 						$html .= htmlspecialchars( $content[ $i ] );
 					}
--- 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.0.11
+ * Version: 6.0.12
  * 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.0.11' );
+	define( 'KIRKI_VERSION', '6.0.12' );
 }

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-12122
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261212,phase:2,deny,status:403,chain,msg:'CVE-2026-12122 via Kirki AJAX endpoint',severity:'CRITICAL',tag:'CVE-2026-12122'"
  SecRule ARGS:action "@streq kirki_dynamic_setting_action" "chain"
    SecRule ARGS:endpoint "@streq get-pre-built-html" "chain"
      SecRule ARGS:elementUrl "@rx ^https?://(127.|10.|172.(1[6-9]|2[0-9]|3[01]).|192.168.|169.254.|0.|0:0:0:0:0:0:0:1|::1)" 
        "t:none,t:lowercase"

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.