Published : August 11, 2026

CVE-2026-13457: InstaWP Connect <= 0.1.3.6 Unauthenticated Cryptographic Key Disclosure PoC, Patch Analysis & Rule

Severity High (CVSS 7.5)
CWE 434
Vulnerable Version 0.1.3.6
Patched Version 0.1.3.7
Disclosed August 10, 2026

Analysis Overview

“`json
{
“analysis”: “Atomic Edge analysis of CVE-2026-13457:nThis vulnerability allows unauthenticated disclosure of the migration api_signature and WordPress database credentials, which can lead to remote code execution on the target site. The InstaWP Connect plugin fails to protect the wp-content/instawpbackups/ directory from HTTP directory listing, exposing an encrypted options file whose filename contains the decryption key material.nnRoot Cause:nThe root cause is the plugin stores the encrypted migration options file as options-{migrate_key}.txt in the wp-content/instawpbackups/ directory without deploying directory-listing guard files such as index.php or .htaccess. The prior code in class-instawp-tools.php had a create_instawpbackups_dir() function that only created the directory (using mkdir with 0777 permissions) but did not write any guard files. Additionally, the plugin’s failure to delete the options file at every terminal migration state (only doing so on ‘completed’ migrations) meant the file could persist after aborted, failed, or timed-out migrations. The filename contains the 40-character migrate_key, which is then hashed with SHA256 to derive the AES-256-CBC passphrase used to decrypt the file’s contents.nnExploitation:nAn attacker first sends a GET request to the /wp-content/instawpbackups/ directory on an Apache server with directory listing enabled. With mod_autoindex, the server returns a page listing all files, including options-{40_character_key}.txt. The attacker copies the migrate_key from the filename. They then compute the AES passphrase as sha256(migrate_key). Next, they send a request to the /wp-content/instawpbackups/options-{key}.txt file to download its contents. Using the derived passphrase, they decrypt the AES-256-CBC encrypted data to recover the api_signature and the database hostname, username, password, and database name. With the api_signature, the attacker can authenticate to the iwp-serve and iwp-dest endpoints to pull or push arbitrary files to the server, ultimately leading to Remote Code Execution by writing a PHP webshell. The exploit is time-limited to the migration window because the option file may be cleaned up after the migration completes.nnPatch Analysis:nThe patch implements multi-layer defense. First, a new protect_instawpbackups_dir() method in class-instawp-tools.php writes index.php (with “Silence is golden.”) and .htaccess (with FilesMatch rules denying access to *.txt, *.sql, and *.sqlite) into the backups root and its plugins/themes subdirectories. The hooks file adds actions on admin_init and upgrader_process_complete to retrofit the guard onto existing installations. Second, to address the file lifecycle, the patch introduces instawp_delete_migration_options_file(), which is called when a migration reaches a terminal state (completed, failed, aborted, or timed out) and in clean_iwp_files_dir(). This function deletes options-*.txt files unless a specific migrate_key is provided. Third, the patch also adds a path traversal fix in iwp-dest/index.php by sanitizing the X-File-Relative-Path header (iwp_sanitize_relative_path) and verifying the final save path resolves inside the site root, closing the ARBITRARY file write that the api_signature disclosure would enable.nnImpact:nExploitation leads to complete site compromise. The attacker obtains the api_signature, which is the primary authentication mechanism for the iwp-serve and iwp-dest migration endpoints. With this signature, they can push a malicious PHP file to the server and achieve Remote Code Execution. They also directly obtain the database credentials, allowing them to exfiltrate or destroy all WordPress data. The attack requires no authentication and only assumes Apache with directory listing enabled in the wp-content/instawpbackups/ directory, which is commonplace. A successful exploit grants full control over the WordPress instance and its underlying filesystem.”,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-13457 – InstaWP Connect <= 0.1.3.6 – Unauthenticated Cryptographic Key Disclosurenn<?phpn/**n * PoC to demonstrate unauthenticated disclosure of api_signature and DB credentials.n * This script interacts with the vulnerable InstaWP Connect plugin (n”,
“modsecurity_rule”: “// Atomic Edge WAF Rule – CVE-2026-13457nSecRule REQUEST_URI “@streq /wp-content/instawpbackups/” “id:20261994,phase:1,deny,status:403,msg:’CVE-2026-13457: InstaWP Connect directory listing attempt’,severity:’CRITICAL’,tag:’CVE-2026-13457′,logdata:’%{MATCHED_VAR}'”n”
}
“`

Differential between vulnerable and patched code

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

Code Diff
--- a/instawp-connect/includes/apis/class-instawp-rest-api-migration.php
+++ b/instawp-connect/includes/apis/class-instawp-rest-api-migration.php
@@ -342,6 +342,12 @@

 			Option::update_option( 'instawp_last_migration_details', $migration_details );

+			// The migration has reached a terminal state, so the encrypted options file is
+			// no longer needed. Deleted here rather than relying on clean_iwp_files_dir()
+			// below, which only runs for 'completed' — an aborted, failed or timed out
+			// migration would otherwise leave the file (and its key-bearing name) on disk.
+			instawp_delete_migration_options_file( Helper::get_args_option( 'migrate_key', $migration_details ) );
+
 			$connect_id = instawp_get_connect_id();

 			// Reset migration state. Connected sites (with a valid connect_id)
--- a/instawp-connect/includes/class-instawp-hooks.php
+++ b/instawp-connect/includes/class-instawp-hooks.php
@@ -17,6 +17,11 @@
 			add_action( 'instawp_connect_connected', array( $this, 'handle_connected' ) );
 			add_action( 'load-tools_page_instawp', array( $this, 'handle_connection_state' ) );
 			add_action( 'admin_init', array( $this, 'generate_api_key' ) );
+			add_action( 'admin_init', array( $this, 'protect_backups_dir' ) );
+			// admin_init only fires when an admin loads wp-admin, so it never reaches a site
+			// updated unattended. Re-assert the guard after this plugin is updated — including
+			// background auto-updates that run in cron with no logged-in user.
+			add_action( 'upgrader_process_complete', array( $this, 'protect_backups_dir_on_upgrade' ), 10, 2 );
 			add_action( 'update_option', array( $this, 'manage_update_option' ), 10, 3 );
 			add_action( 'init', array( $this, 'handle_hard_disable_seo_visibility' ) );
 			add_action( 'admin_init', array( $this, 'handle_clear_all' ), 999 );
@@ -45,6 +50,88 @@
 			instawp_set_staging_sites_list();
 		}

+		/**
+		 * Retrofit the directory-listing guard onto an existing backups directory.
+		 *
+		 * Sites that already ran a migration have the directory — and often leftover
+		 * options-{key}.txt files — on disk right now, while create_instawpbackups_dir()
+		 * is only reached when a migration starts. Without this they would stay listable
+		 * until their next migration.
+		 *
+		 * The option check keeps this to one filesystem probe per guard revision instead
+		 * of one on every admin request.
+		 *
+		 * @return void
+		 */
+		public function protect_backups_dir() {
+
+			// Wrapped so a filesystem or environment edge case can never fatal the
+			// admin_init request this runs on.
+			try {
+				$guard_version = '1';
+
+				if ( Option::get_option( 'instawp_backups_dir_guarded' ) === $guard_version ) {
+					return;
+				}
+
+				// Only record the guard as done once it is actually in place, so a transient
+				// permission problem is retried on a later request instead of latching.
+				if ( InstaWP_Tools::protect_instawpbackups_dir() ) {
+					Option::update_option( 'instawp_backups_dir_guarded', $guard_version );
+				}
+			} catch ( Throwable $th ) {
+				Helper::add_error_log( array( 'title' => 'instawp: protect_backups_dir failed' ), $th );
+			}
+		}
+
+		/**
+		 * Re-assert the directory-listing guard after this plugin is updated.
+		 *
+		 * Fires on upgrader_process_complete, which unlike admin_init runs during
+		 * unattended background updates (auto-update via cron) where no admin is logged
+		 * in. Scoped to updates that actually include this plugin so unrelated plugin,
+		 * theme or core updates are ignored. Clears the guard flag first so the version
+		 * gate in protect_backups_dir() cannot short-circuit the re-check.
+		 *
+		 * @param WP_Upgrader $upgrader Upgrader instance (unused).
+		 * @param array       $options  Update context: type, action and affected items.
+		 *
+		 * @return void
+		 */
+		public function protect_backups_dir_on_upgrade( $upgrader, $options ) {
+
+			// Runs on upgrader_process_complete; a failure here must not break the update.
+			try {
+				if ( empty( $options['type'] ) || 'plugin' !== $options['type'] ) {
+					return;
+				}
+
+				$plugin_basename = defined( 'INSTAWP_PLUGIN_SLUG' )
+					? INSTAWP_PLUGIN_SLUG . '/' . INSTAWP_PLUGIN_SLUG . '.php'
+					: 'instawp-connect/instawp-connect.php';
+
+				// The affected plugins arrive as 'plugins' for bulk/auto updates and 'plugin'
+				// for a single manual update; normalise both into one list.
+				$updated_plugins = array();
+				if ( ! empty( $options['plugins'] ) && is_array( $options['plugins'] ) ) {
+					$updated_plugins = $options['plugins'];
+				} elseif ( ! empty( $options['plugin'] ) ) {
+					$updated_plugins = array( $options['plugin'] );
+				}
+
+				if ( ! in_array( $plugin_basename, $updated_plugins, true ) ) {
+					return;
+				}
+
+				// Force a re-check even if a previous run already recorded the guard version.
+				Option::delete_option( 'instawp_backups_dir_guarded' );
+
+				$this->protect_backups_dir();
+			} catch ( Throwable $th ) {
+				Helper::add_error_log( array( 'title' => 'instawp: protect_backups_dir_on_upgrade failed' ), $th );
+			}
+		}
+
 		public function handle_connection_state() {
 			if ( ! instawp_is_connected_origin_valid() ) {
 				instawp_reset_running_migration( 'hard' );
--- a/instawp-connect/includes/class-instawp-tools.php
+++ b/instawp-connect/includes/class-instawp-tools.php
@@ -104,19 +104,204 @@

 	public static function create_instawpbackups_dir( $instawpbackups_dir = '' ) {

-		if ( empty( $instawpbackups_dir ) ) {
-			$instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+		// Wrapped so a filesystem or environment edge case can never fatal the caller.
+		try {
+			if ( empty( $instawpbackups_dir ) ) {
+				// Guard against being called before the constants that form the default path are
+				// defined; without them there is no directory to create or protect.
+				if ( ! defined( 'WP_CONTENT_DIR' ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+					return false;
+				}
+
+				$instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+			}
+
+			$dir_created = false;
+
+			if ( ! is_dir( $instawpbackups_dir ) ) {
+				// Permissions are intentionally left at 0777: some shared hosts run the web
+				// server and PHP as different users and both need to write into this tree.
+				$dir_created = mkdir( $instawpbackups_dir, 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+			}
+
+			// Always re-assert the listing guards, not only when the directory was just
+			// created. On every site that has already run a migration the directory exists,
+			// so a guard written inside the mkdir branch above would never reach them.
+			self::protect_instawpbackups_dir( $instawpbackups_dir );
+
+			return $dir_created;
+		} catch ( Throwable $th ) {
+			Helper::add_error_log( array( 'title' => 'instawp: create_instawpbackups_dir failed' ), $th );
+
+			return false;
 		}
+	}
+
+	/**
+	 * Names of the files that keep a directory from being listed over HTTP.
+	 * Kept in one place so the cleanup routines can recognise and preserve them.
+	 *
+	 * @return string[]
+	 */
+	public static function get_dir_guard_files() {
+		return array( 'index.php', 'index.html', '.htaccess' );
+	}
+
+	/**
+	 * Check whether a path is one of the directory-listing guard files.
+	 *
+	 * @param string $file_path Full path or bare filename.
+	 *
+	 * @return bool
+	 */
+	public static function is_dir_guard_file( $file_path ) {
+		return in_array( basename( $file_path ), self::get_dir_guard_files(), true );
+	}
+
+	/**
+	 * Check whether a directory is the backups directory or one of its sub-directories.
+	 *
+	 * Used to scope guard-file handling to the backups tree only, since the cleanup
+	 * helpers are also pointed at ABSPATH/iwp-serve and ABSPATH/iwp-dest.
+	 *
+	 * @param string $dir_path Directory path to test.
+	 *
+	 * @return bool
+	 */
+	public static function is_inside_backups_dir( $dir_path ) {
+
+		if ( empty( $dir_path ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+			return false;
+		}
+
+		$backups_dir = wp_normalize_path( untrailingslashit( WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR ) );
+		$dir_path    = wp_normalize_path( untrailingslashit( $dir_path ) );
+
+		return $dir_path === $backups_dir || 0 === strpos( $dir_path, $backups_dir . '/' );
+	}
+
+	/**
+	 * Drop the directory-listing guards into the backups directory so it cannot be
+	 * listed or its credential files fetched over HTTP.
+	 *
+	 * This directory holds `options-{migrate_key}.txt`, whose filename doubles as the
+	 * key material used to decrypt its own contents (the site's database credentials
+	 * and the migration api_signature). Two guard files are written:
+	 *
+	 * - index.php ("Silence is golden.") — makes mod_dir serve it instead of letting
+	 *   mod_autoindex generate a listing that would leak the key through the filename.
+	 * - .htaccess — denies direct HTTP access to the sensitive migration artifacts
+	 *   (`*.txt` options files, plus any `*.sql`/`*.sqlite` database dumps) while
+	 *   leaving `*.zip` reachable, because plugin/theme sync serves `plugins/*.zip`
+	 *   and `themes/*.zip` out of this same tree over HTTP. The rule is extension-scoped
+	 *   rather than `deny from all` for that reason, and the deny is safe for the plugin
+	 *   itself, which only ever reads these files from the filesystem (never over HTTP).
+	 *   It prefers the mod_access_compat form (Order/Deny — the same the migration-log
+	 *   guard uses, permitted under `AllowOverride Limit`) and falls back to
+	 *   `Require all denied` only where mod_access_compat is absent, so it works on
+	 *   Apache 2.4 and 2.2 and is inert (not a 500) where .htaccess overrides are
+	 *   disabled.
+	 *
+	 *   This is Apache-layer defense-in-depth only: an nginx front-end (or nginx+Apache
+	 *   host that serves static files with `try_files $uri`) returns the file directly
+	 *   without consulting .htaccess, so it does not protect there. The cross-server
+	 *   guarantee is instead that the options file is force-deleted at every terminal
+	 *   migration state — see instawp_delete_migration_options_file().
+	 *
+	 * @param string $instawpbackups_dir Directory to protect. Defaults to the backups dir.
+	 *
+	 * @return bool True when every existing target directory carries a guard.
+	 */
+	public static function protect_instawpbackups_dir( $instawpbackups_dir = '' ) {

-		if ( ! is_dir( $instawpbackups_dir ) ) {
-			if ( mkdir( $instawpbackups_dir, 0777, true ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+		// Wrapped so a filesystem or environment edge case can never fatal the request
+		// that triggered the guard (admin_init, upgrader_process_complete, sync, migration).
+		try {
+			if ( empty( $instawpbackups_dir ) ) {
+				// The default path is derived from these constants, so bail defensively if the
+				// method is reached before WordPress (or the plugin bootstrap) has defined them —
+				// e.g. from a hook that fires very early. Returning false leaves the guard flag
+				// unset so a later request retries.
+				if ( ! defined( 'WP_CONTENT_DIR' ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+					return false;
+				}
+
+				$instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+			}
+
+			$instawpbackups_dir = untrailingslashit( $instawpbackups_dir );
+
+			if ( ! is_dir( $instawpbackups_dir ) ) {
+				// Nothing on disk to protect yet — create_instawpbackups_dir() guards the
+				// directory at creation time, so this is a success rather than a failure.
 				return true;
-			} else {
-				return false;
 			}
-		}

-		return false;
+			// Guard the backups root plus the two sub-directories that hold sync artifacts.
+			// migration-log/ writes its own guards in InstaWP_Migrate_Log::get_path().
+			$dirs_to_protect = array(
+				$instawpbackups_dir,
+				$instawpbackups_dir . DIRECTORY_SEPARATOR . 'plugins',
+				$instawpbackups_dir . DIRECTORY_SEPARATOR . 'themes',
+			);
+
+			// The two guard files written into each directory. Kept in a map so each is
+			// written independently — a directory that already carries index.php from an
+			// earlier run still gets the .htaccess added on a later call. The .htaccess
+			// denies only sensitive extensions (*.txt/*.sql/*.sqlite) so it never blocks
+			// the *.zip files sync serves over HTTP. It prefers the mod_access_compat form
+			// (permitted under AllowOverride Limit, like the migration-log guard) and
+			// falls back to Require only where that module is absent.
+			$deny_pattern = '<FilesMatch ".(txt|sql|sqlite)$">';
+			$guard_files  = array(
+				'index.php' => "<?phpn// Silence is golden.n",
+				'.htaccess' => implode( "n", array(
+					'# InstaWP: deny direct HTTP access to migration credential/database artifacts',
+					'# (options-{key}.txt and any .sql/.sqlite). PHP filesystem reads are unaffected;',
+					'# plugin/theme sync .zip stays reachable.',
+					'<IfModule mod_access_compat.c>',
+					"t" . $deny_pattern,
+					"tt" . 'Order Allow,Deny',
+					"tt" . 'Deny from all',
+					"t" . '</FilesMatch>',
+					'</IfModule>',
+					'<IfModule !mod_access_compat.c>',
+					"t" . $deny_pattern,
+					"tt" . 'Require all denied',
+					"t" . '</FilesMatch>',
+					'</IfModule>',
+					'',
+				) ),
+			);
+
+			$all_dirs_guarded = true;
+
+			foreach ( $dirs_to_protect as $dir_to_protect ) {
+				if ( ! is_dir( $dir_to_protect ) ) {
+					continue;
+				}
+
+				foreach ( $guard_files as $guard_name => $guard_contents ) {
+					$guard_path = $dir_to_protect . DIRECTORY_SEPARATOR . $guard_name;
+
+					if ( file_exists( $guard_path ) ) {
+						continue;
+					}
+
+					// A failed write is reported so the caller does not record the directory
+					// as guarded and can retry later.
+					if ( ! is_writable( $dir_to_protect ) || ! @file_put_contents( $guard_path, $guard_contents ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+						$all_dirs_guarded = false;
+					}
+				}
+			}
+
+			return $all_dirs_guarded;
+		} catch ( Throwable $th ) {
+			Helper::add_error_log( array( 'title' => 'instawp: protect_instawpbackups_dir failed' ), $th );
+
+			return false;
+		}
 	}

 	public static function clean_instawpbackups_dir( $instawpbackups_dir = '', $clean_self = false ) {
@@ -129,12 +314,21 @@
 			return false;
 		}

+		// Preserve the index.php listing guard, but only inside the backups tree and only
+		// when the directory itself is being kept. This same method is also called on
+		// ABSPATH/iwp-serve and ABSPATH/iwp-dest, where deleting index.php is the entire
+		// point, and a surviving guard would additionally make the rmdir() below fail.
+		$keep_dir_guards = ! $clean_self && self::is_inside_backups_dir( $instawpbackups_dir );
+
 		while ( false !== ( $file = readdir( $instawpbackups_dir_handle ) ) ) {
 			if ( $file !== '.' && $file !== '..' ) {
 				$file_path = $instawpbackups_dir . DIRECTORY_SEPARATOR . $file;
 				if ( file_exists( $file_path ) ) {
 					if ( is_dir( $file_path ) ) {
 						self::clean_instawpbackups_dir( $file_path );
+					} elseif ( $keep_dir_guards && self::is_dir_guard_file( $file ) ) {
+						// Keep the directory listing guard in place across cleanups.
+						continue;
 					} elseif ( ! function_exists( 'instawp_is_options_file_protected' ) || ! instawp_is_options_file_protected( $file_path ) ) {
 						// Skip deletion of options-{key}.txt belonging to an active migration.
 						// The iwp-serve endpoint needs this file for DB credentials during pull.
@@ -2231,6 +2425,14 @@
 	 */
 	public static function clean_iwp_files_dir() {
 		try {
+			// Drop the encrypted migration options files first. This runs before (and
+			// independently of) the $keep_files check below: that setting is about
+			// retaining database dumps for debugging, and must never extend the life of a
+			// file whose own name is the key to the credentials inside it.
+			if ( function_exists( 'instawp_delete_migration_options_file' ) ) {
+				instawp_delete_migration_options_file();
+			}
+
 			// Delete IWP files : Start
 			$files = array();

--- a/instawp-connect/includes/functions-pull-push.php
+++ b/instawp-connect/includes/functions-pull-push.php
@@ -992,6 +992,60 @@
 	}
 }

+if ( ! function_exists( 'iwp_sanitize_relative_path' ) ) {
+	/**
+	 * Sanitise a migration file path received over the network.
+	 *
+	 * Leading separators are stripped rather than rejected. That is deliberate and is
+	 * exactly what the receiver already does today: joining "/wp-content/x.php" to the
+	 * root yields "<root>//wp-content/x.php", which resolves to the same file as the
+	 * stripped form. Rejecting them instead would break senders that legitimately produce
+	 * one — notably a Windows source, where iwp-serve strips with DIRECTORY_SEPARATOR
+	 * ("") and so leaves a leading "/" on forward-slash paths untouched.
+	 *
+	 * What genuinely cannot be made safe is rejected outright: null bytes, `..` segments
+	 * and Windows drive letters. Those never occur in a well-formed transfer, since the
+	 * source derives every path from a filesystem walk rooted at WP_ROOT.
+	 *
+	 * @param string $relative_path Value taken from the X-File-Relative-Path header.
+	 *
+	 * @return string|false Sanitised root-relative path, or false when unusable.
+	 */
+	function iwp_sanitize_relative_path( $relative_path ) {
+
+		if ( ! is_string( $relative_path ) || '' === trim( $relative_path ) ) {
+			return false;
+		}
+
+		// Null byte truncation.
+		if ( false !== strpos( $relative_path, "" ) ) {
+			return false;
+		}
+
+		// Compare against a forward-slash copy so a Windows-style path cannot slip past
+		// the segment checks. The value returned keeps its original separators.
+		$normalized_path = str_replace( '\', '/', $relative_path );
+
+		// Windows drive letter — the source failed to strip WP_ROOT, so the path is
+		// meaningless on this machine. Already broken today; fail loudly instead.
+		if ( preg_match( '#^[a-zA-Z]:/#', $normalized_path ) ) {
+			return false;
+		}
+
+		// Any parent-directory segment, wherever it appears in the path.
+		foreach ( explode( '/', $normalized_path ) as $path_segment ) {
+			if ( '..' === $path_segment ) {
+				return false;
+			}
+		}
+
+		// Strip leading separators so the path is unambiguously root-relative.
+		$relative_path = ltrim( $relative_path, "/\" );
+
+		return '' === $relative_path ? false : $relative_path;
+	}
+}
+
 if ( ! function_exists( 'iwp_get_migration_file_paths' ) ) {
 	/**
 	 * Absolute paths of every per-migration file identified by $key_hash.
--- a/instawp-connect/includes/functions.php
+++ b/instawp-connect/includes/functions.php
@@ -279,6 +279,65 @@
 	}
 }

+if ( ! function_exists( 'instawp_delete_migration_options_file' ) ) {
+	/**
+	 * Delete the encrypted migration options file(s) from the backups directory.
+	 *
+	 * The filename (`options-{migrate_key}.txt`) doubles as the key material used to
+	 * decrypt its own contents — the site's database credentials and the migration
+	 * api_signature — so it must not outlive the migration that produced it.
+	 *
+	 * Unlike the loops in instawp_reset_running_migration() this ignores the
+	 * instawp_is_options_file_protected() guard: callers use it at points where the
+	 * migration is already finished, so there is no active migration left to protect.
+	 *
+	 * @param string $migrate_key Delete only this migration's file. Empty string deletes
+	 *                            every options file left in the directory.
+	 *
+	 * @return int Number of files deleted.
+	 */
+	function instawp_delete_migration_options_file( $migrate_key = '' ) {
+
+		// Runs at migration terminal states and on reset; a failure must never fatal those
+		// flows, so the whole body is wrapped.
+		try {
+			if ( ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) || ! defined( 'WP_CONTENT_DIR' ) ) {
+				return 0;
+			}
+
+			$backup_dir = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . INSTAWP_DEFAULT_BACKUP_DIR . DIRECTORY_SEPARATOR;
+
+			if ( ! is_dir( $backup_dir ) ) {
+				return 0;
+			}
+
+			// A specific key targets one file; an empty key sweeps whatever is left behind by
+			// migrations that crashed before reaching their own cleanup. basename() keeps a
+			// malformed key from ever resolving outside the backups directory.
+			if ( ! empty( $migrate_key ) ) {
+				$options_files = array( $backup_dir . 'options-' . basename( $migrate_key ) . '.txt' );
+			} else {
+				$options_files = (array) glob( $backup_dir . 'options-*.txt' );
+			}
+
+			$deleted_count = 0;
+
+			foreach ( $options_files as $options_file ) {
+				if ( ! empty( $options_file ) && is_file( $options_file ) ) {
+					wp_delete_file( $options_file );
+					$deleted_count ++;
+				}
+			}
+
+			return $deleted_count;
+		} catch ( Throwable $th ) {
+			Helper::add_error_log( array( 'title' => 'instawp: instawp_delete_migration_options_file failed' ), $th );
+
+			return 0;
+		}
+	}
+}
+
 if ( ! function_exists( 'instawp_reset_running_migration' ) ) {
 	/**
 	 * Reset running migration
@@ -324,6 +383,12 @@
 			$files_to_delete = array_diff( $files_to_delete, array( '.', '..' ) );

 			foreach ( $files_to_delete as $file ) {
+				// Leave the index.php listing guard in place — the directory survives this
+				// cleanup, so removing its guard would re-expose it to directory listing.
+				if ( class_exists( 'InstaWP_Tools' ) && InstaWP_Tools::is_dir_guard_file( $file ) ) {
+					continue;
+				}
+
 				if ( is_file( $instawp_backup_dir . $file ) && ! instawp_is_options_file_protected( $instawp_backup_dir . $file ) ) {
 					wp_delete_file( $instawp_backup_dir . $file );
 				}
--- a/instawp-connect/includes/sync/class-instawp-sync-plugin-theme.php
+++ b/instawp-connect/includes/sync/class-instawp-sync-plugin-theme.php
@@ -207,6 +207,12 @@
 			}
 		}

+		// Sync creates these directories with wp_mkdir_p() rather than through
+		// InstaWP_Tools::create_instawpbackups_dir(), so the listing guard has to be
+		// asserted here too — otherwise a site that only ever syncs (and never migrates)
+		// would leave the backups tree listable.
+		InstaWP_Tools::protect_instawpbackups_dir();
+
 		$slug = basename( $source );

 		// Always use slug-based naming
--- a/instawp-connect/instawp-connect.php
+++ b/instawp-connect/instawp-connect.php
@@ -8,7 +8,7 @@
  * @wordpress-plugin
  * Plugin Name:       InstaWP Connect
  * Description:       1-click WordPress plugin for Staging, Migrations, Management, Sync and Companion plugin for InstaWP.
- * Version:           0.1.3.6
+ * Version:           0.1.3.7
  * Author:            InstaWP Team
  * Author URI:        https://instawp.com/
  * License:           GPL-3.0+
@@ -28,7 +28,7 @@

 global $wpdb;

-defined( 'INSTAWP_PLUGIN_VERSION' ) || define( 'INSTAWP_PLUGIN_VERSION', '0.1.3.6' );
+defined( 'INSTAWP_PLUGIN_VERSION' ) || define( 'INSTAWP_PLUGIN_VERSION', '0.1.3.7' );
 defined( 'INSTAWP_API_DOMAIN_PROD' ) || define( 'INSTAWP_API_DOMAIN_PROD', 'https://app.instawp.io' );

 defined( 'INSTAWP_PLUGIN_URL' ) || define( 'INSTAWP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
--- a/instawp-connect/iwp-dest/index.php
+++ b/instawp-connect/iwp-dest/index.php
@@ -101,12 +101,41 @@
 $file_type          = isset( $_SERVER['HTTP_X_FILE_TYPE'] ) ? trim( $_SERVER['HTTP_X_FILE_TYPE'] ) : 'single';
 $req_order          = isset( $_GET['r'] ) ? intval( $_GET['r'] ) : 1;

+// Containment guard. The header is caller-controlled once a valid signature is held, and
+// everything below writes straight to the path it names. Leading separators are stripped
+// (which resolves to the same file the receiver already writes today), while `..`
+// segments, null bytes and drive letters are rejected.
+// Preserve the original header value for diagnostics before it is replaced by the
+// sanitised result below, so a rejected transfer is debuggable from the response.
+// Strip CR/LF/NUL so it is safe to echo back in a response header (PHP's header()
+// already refuses line breaks, this also keeps the value readable in migration logs).
+$requested_relative_path = str_replace( array( "r", "n", "" ), '', $file_relative_path );
+
+$file_relative_path = iwp_sanitize_relative_path( $file_relative_path );
+
+if ( false === $file_relative_path ) {
+	header( 'x-iwp-status: false' );
+	header( 'x-iwp-message: The migration script rejected an invalid file path. Provided path: ' . $requested_relative_path );
+	die();
+}
+
 if ( in_array( $file_relative_path, $excluded_paths ) ) {
 	exit( 0 );
 }

 $file_save_path = $root_dir_path . DIRECTORY_SEPARATOR . $file_relative_path;

+// Belt-and-braces: confirm the joined path really does resolve inside the site root, in
+// case $root_dir_path itself ever carries a trailing separator or unusual prefix.
+$root_dir_prefix     = rtrim( str_replace( '\', '/', $root_dir_path ), '/' ) . '/';
+$file_save_path_test = str_replace( '\', '/', $file_save_path );
+
+if ( 0 !== strpos( $file_save_path_test, $root_dir_prefix ) ) {
+	header( 'x-iwp-status: false' );
+	header( 'x-iwp-message: The migration script rejected a file path outside the site root. Provided path: ' . $requested_relative_path );
+	die();
+}
+
 if ( in_array( $file_save_path, $excluded_paths ) || str_contains( $file_save_path, 'instawp-autologin' ) ) {
 	exit( 0 );
 }
--- a/instawp-connect/vendor/composer/installed.php
+++ b/instawp-connect/vendor/composer/installed.php
@@ -22,7 +22,7 @@
         'instawp/connect-helpers' => array(
             'pretty_version' => 'dev-main',
             'version' => 'dev-main',
-            'reference' => 'e40678f5a7fd3e678e2706a0e72af76b97a3fd5f',
+            'reference' => 'ded3bdc61188b9174425771efc397c9c5c1ed364',
             'type' => 'library',
             'install_path' => __DIR__ . '/../instawp/connect-helpers',
             'aliases' => array(
--- a/instawp-connect/vendor/instawp/connect-helpers/src/WPConfig.php
+++ b/instawp-connect/vendor/instawp/connect-helpers/src/WPConfig.php
@@ -28,6 +28,443 @@
         'DOMAIN_CURRENT_SITE',
     ];

+    // InstaCache (Valkey/Redis) object-cache config is platform-managed and must never be
+    // round-tripped through the Config Manager: array-valued constants (WP_REDIS_PASSWORD =
+    // [acl_user, acl_pass]; WP_REDIS_SERVERS/CLUSTER/SENTINEL/SHARDS/*_GROUPS) collapse to a
+    // mangled string on save, breaking object-cache auth. A prefix match blacklists the whole
+    // family (present and future) rather than an enumerated, quickly-stale list.
+    protected $blacklisted_prefixes = [
+        'WP_REDIS_',
+    ];
+
+    protected function is_blacklisted( $constant ) {
+        if ( in_array( $constant, $this->blacklisted, true ) ) {
+            return true;
+        }
+        foreach ( $this->blacklisted_prefixes as $prefix ) {
+            if ( 0 === strpos( $constant, $prefix ) ) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Control structures whose body we treat as a conditional scope.
+     *
+     * @return array
+     */
+    protected function scope_keywords() {
+        $keywords = [ T_IF, T_ELSEIF, T_ELSE, T_WHILE, T_FOR, T_FOREACH, T_SWITCH, T_DO, T_TRY, T_CATCH, T_FUNCTION ];
+
+        foreach ( [ 'T_FINALLY', 'T_FN', 'T_MATCH' ] as $maybe ) {
+            if ( defined( $maybe ) ) {
+                $keywords[] = constant( $maybe );
+            }
+        }
+
+        return $keywords;
+    }
+
+    /**
+     * Control structures that support PHP's alternative (colon) syntax.
+     *
+     * Deliberately excludes T_FUNCTION so a return type (`function f(): void`) is not
+     * mistaken for the start of a block.
+     *
+     * @return array
+     */
+    protected function alternative_syntax_keywords() {
+        return [ T_IF, T_ELSEIF, T_ELSE, T_WHILE, T_FOR, T_FOREACH, T_SWITCH ];
+    }
+
+    /**
+     * Names of constants whose define() does NOT sit at the top level of wp-config.php.
+     *
+     * WPConfigTransformer parses wp-config.php with a flat regex that has no awareness of
+     * enclosing scope, so a define() nested in a block — for example the InstaCache
+     * drop-in's
+     *
+     *     if ( defined( 'WP_CLI' ) && WP_CLI ) {
+     *         define( 'WP_REDIS_DISABLED', true );
+     *     }
+     *
+     * — is reported exactly like a top-level one. Surfacing such a constant to the Config
+     * Manager is wrong twice over: it advertises a value that does not apply to ordinary
+     * requests, and saving it back rewrites code inside a branch the caller never saw.
+     *
+     * A define() whose every enclosing condition names the constant itself — the ordinary
+     * `if ( ! defined( 'FOO' ) ) { define( 'FOO', ... ); }` idempotency guard — is
+     * unconditional in effect, so it stays manageable.
+     *
+     * Deliberate limitations, all of which fail towards today's behaviour rather than
+     * towards hiding a constant that is genuinely global:
+     *  - Without the tokenizer extension nothing is filtered at all.
+     *  - The result is keyed by NAME, so a constant defined both at the top level and
+     *    inside a block is left alone entirely. That is intentional: the transformer
+     *    rewrites by matching source text and we cannot tell which occurrence it would
+     *    edit, so refusing is the only safe answer.
+     *  - A brace-less body that immediately opens another statement
+     *    (`if ( $a )` newline `if ( ! defined( 'FOO' ) ) { ... }`) is judged on the inner
+     *    condition only; tracking the outer one needs statement-level parsing.
+     *
+     * @param string $src wp-config.php source.
+     *
+     * @return array Constant name => true.
+     */
+    protected function scoped_constants( $src ) {
+        if ( ! function_exists( 'token_get_all' ) ) {
+            return [];
+        }
+
+        $tokens = @token_get_all( $src );
+
+        if ( empty( $tokens ) ) {
+            return [];
+        }
+
+        $scope_keywords = $this->scope_keywords();
+        $alt_keywords   = $this->alternative_syntax_keywords();
+        $end_keywords   = [ T_ENDIF, T_ENDWHILE, T_ENDFOR, T_ENDFOREACH, T_ENDSWITCH ];
+
+        $trivia = [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ];
+
+        $scoped  = [];
+        $stack   = [];   // One entry per open block: the header text that introduced it.
+        $header  = null; // Header text of the control structure currently being read.
+        $keyword = null; // Which keyword started that header.
+        $paren   = 0;    // Parenthesis depth, so a ternary ':' is not read as a block opener.
+        $expects_colon = false; // We are exactly at the ':' position of alternative syntax.
+        $condition_closed = false; // The current header's own condition has been closed.
+        $total   = count( $tokens );
+
+        for ( $index = 0; $index < $total; $index++ ) {
+            $token = $tokens[ $index ];
+
+            if ( is_array( $token ) ) {
+                $id   = $token[0];
+                $text = $token[1];
+
+                if ( in_array( $id, $trivia, true ) ) {
+                    if ( null !== $header ) {
+                        $header .= $text;
+                    }
+                    continue;
+                }
+
+                /* A close tag ends the statement exactly like a semicolon, so a brace-less
+                   body such as `if ( $a ) define( 'X', 1 ) ?>` must not leave the header
+                   open over the constants that follow it. */
+                if ( T_CLOSE_TAG === $id ) {
+                    $header  = null;
+                    $keyword = null;
+                    $expects_colon    = false;
+                    $condition_closed = false;
+                    continue;
+                }
+
+                if ( in_array( $id, $end_keywords, true ) ) {
+                    array_pop( $stack );
+                    $expects_colon = false;
+                    continue;
+                }
+
+                // A '{' that opens string interpolation ("{$a}", "${a}") is closed by a plain
+                // '}', so it has to be balanced here or every later define() looks nested.
+                if ( T_CURLY_OPEN === $id || T_DOLLAR_OPEN_CURLY_BRACES === $id ) {
+                    $stack[]       = '';
+                    $expects_colon = false;
+                    continue;
+                }
+
+                // A control keyword only opens a scope at the top of a statement. Inside
+                // parentheses it belongs to something else — a closure in a condition — and
+                // must not replace the header being read. PHP 7 also allows reserved words as
+                // member names, so `function for( $k ): string` still lexes T_FOR: taking that
+                // as a `for` header would read its return-type ':' as alternative syntax and
+                // push a scope nothing pops.
+                if ( in_array( $id, $scope_keywords, true ) && 0 === $paren && ! $this->is_member_name( $tokens, $index ) ) {
+                    $header  = $text;
+                    $keyword = $id;
+                    // `else` takes no condition, so its alternative-syntax ':' comes next.
+                    $expects_colon    = ( T_ELSE === $id );
+                    $condition_closed = ( T_ELSE === $id );
+                    continue;
+                }
+
+                if ( $this->is_define_token( $token ) && $this->is_define_call( $tokens, $index ) ) {
+                    // Inside a block, or a brace-less body such as `if ( ... ) define( ... );`.
+                    if ( ! empty( $stack ) || null !== $header ) {
+                        $enclosing = $stack;
+                        if ( null !== $header ) {
+                            $enclosing[] = $header;
+                        }
+
+                        $name = $this->define_name( $tokens, $index );
+
+                        if ( '' !== $name && ! $this->guards_itself( $enclosing, $name ) ) {
+                            $scoped[ $name ] = true;
+                        }
+                    }
+                    $expects_colon = false;
+                    continue;
+                }
+
+                if ( null !== $header ) {
+                    $header .= $text;
+                }
+
+                $expects_colon = false;
+                continue;
+            }
+
+            if ( '(' === $token ) {
+                $paren++;
+                $expects_colon = false;
+            } elseif ( ')' === $token ) {
+                $paren--;
+                // Only the ':' immediately after a control structure's OWN closing ')' opens an
+                // alternative-syntax block — hence $condition_closed, which stops a later call
+                // in a brace-less body from re-arming it. Without this, the ':' of a ternary
+                // (`if ( $a ) $b = $c ? d( $e ) : f();`) pushes a scope that nothing pops, and
+                // every constant below it in the file silently disappears.
+                if ( 0 === $paren && null !== $header && ! $condition_closed ) {
+                    $expects_colon    = true;
+                    $condition_closed = true;
+                }
+            }
+
+            if ( '{' === $token ) {
+                // A '{' inside parentheses is a closure or match body sitting in the middle of
+                // a condition — `if ( array_filter( $a, function ( $v ) { … } ) ) :`. It opens a
+                // scope, but the header it interrupts is still being read, so keep it: nulling
+                // it would stop the following ':' from pushing and leave the matching `endif`
+                // to pop the enclosing block instead.
+                if ( $paren > 0 ) {
+                    $stack[] = '';
+                    $expects_colon = false;
+                    continue;
+                }
+
+                $stack[] = ( null === $header ) ? '' : $header;
+                $header  = null;
+                $keyword = null;
+                $expects_colon    = false;
+                $condition_closed = false;
+                continue;
+            }
+
+            if ( '}' === $token ) {
+                array_pop( $stack );
+                $expects_colon = false;
+                continue;
+            }
+
+            if ( ':' === $token && $expects_colon && null !== $header && 0 === $paren && in_array( $keyword, $alt_keywords, true ) ) {
+                // `elseif:` / `else:` continue the same if-chain that a single `endif` closes,
+                // so they replace the open scope rather than nesting inside it.
+                if ( T_ELSEIF === $keyword || T_ELSE === $keyword ) {
+                    array_pop( $stack );
+                }
+
+                $stack[] = $header;
+                $header  = null;
+                $keyword = null;
+                $expects_colon    = false;
+                $condition_closed = false;
+                continue;
+            }
+
+            if ( ';' === $token && 0 === $paren ) {
+                // The statement ended without opening a block (a brace-less body, or `do ... while;`).
+                $header  = null;
+                $keyword = null;
+                $expects_colon    = false;
+                $condition_closed = false;
+                continue;
+            }
+
+            if ( null !== $header ) {
+                $header .= $token;
+            }
+
+            if ( ')' !== $token ) {
+                $expects_colon = false;
+            }
+        }
+
+        return $scoped;
+    }
+
+    /**
+     * Whether a token names the `define` function, in either the plain (`define`) or
+     * fully qualified (`define`) form. PHP 8 lexes the latter as a single token.
+     *
+     * @param array|string $token
+     *
+     * @return bool
+     */
+    protected function is_define_token( $token ) {
+        if ( ! is_array( $token ) ) {
+            return false;
+        }
+
+        $ids = [ T_STRING ];
+
+        if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) {
+            $ids[] = constant( 'T_NAME_FULLY_QUALIFIED' );
+        }
+
+        if ( ! in_array( $token[0], $ids, true ) ) {
+            return false;
+        }
+
+        return 0 === strcasecmp( ltrim( $token[1], '\' ), 'define' );
+    }
+
+    /**
+     * Index of the next token after $index that is not whitespace or a comment.
+     *
+     * @param array $tokens
+     * @param int   $index
+     * @param int   $step   1 to scan forwards, -1 to scan backwards.
+     *
+     * @return int|null
+     */
+    protected function next_significant( $tokens, $index, $step = 1 ) {
+        $trivia = [ T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ];
+        $total  = count( $tokens );
+
+        for ( $cursor = $index + $step; $cursor >= 0 && $cursor < $total; $cursor += $step ) {
+            $token = $tokens[ $cursor ];
+
+            if ( is_array( $token ) && in_array( $token[0], $trivia, true ) ) {
+                continue;
+            }
+
+            return $cursor;
+        }
+
+        return null;
+    }
+
+    /**
+     * Whether the token at $index is being used as a member/function NAME rather than as
+     * the keyword it lexes to. PHP 7 allows reserved words after `function`, `->`, `?->`
+     * and `::`, so `function for( $k )` and `$obj->if()` both reach us as control keywords.
+     *
+     * @param array $tokens
+     * @param int   $index
+     *
+     * @return bool
+     */
+    protected function is_member_name( $tokens, $index ) {
+        $before = $this->next_significant( $tokens, $index, -1 );
+
+        if ( null === $before || ! is_array( $tokens[ $before ] ) ) {
+            return false;
+        }
+
+        $names_follow = [ T_FUNCTION, T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_CONST ];
+
+        if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
+            $names_follow[] = constant( 'T_NULLSAFE_OBJECT_OPERATOR' );
+        }
+
+        return in_array( $tokens[ $before ][0], $names_follow, true );
+    }
+
+    /**
+     * Whether the `define` token at $index is a real function call and not a method
+     * name (`$obj->define(...)`, `Foo::define(...)`) or a declaration.
+     *
+     * @param array $tokens
+     * @param int   $index
+     *
+     * @return bool
+     */
+    protected function is_define_call( $tokens, $index ) {
+        $rejected = [ T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NS_SEPARATOR ];
+
+        if ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) ) {
+            $rejected[] = constant( 'T_NULLSAFE_OBJECT_OPERATOR' );
+        }
+
+        $before = $this->next_significant( $tokens, $index, -1 );
+
+        if ( null !== $before && is_array( $tokens[ $before ] ) && in_array( $tokens[ $before ][0], $rejected, true ) ) {
+            return false;
+        }
+
+        $after = $this->next_significant( $tokens, $index );
+
+        return null !== $after && '(' === $tokens[ $after ];
+    }
+
+    /**
+     * The constant name of the define() call whose `define` token sits at $index.
+     *
+     * @param array $tokens
+     * @param int   $index
+     *
+     * @return string Empty when the name is not a plain string literal.
+     */
+    protected function define_name( $tokens, $index ) {
+        $open = $this->next_significant( $tokens, $index );
+
+        if ( null === $open || '(' !== $tokens[ $open ] ) {
+            return '';
+        }
+
+        $literal = $this->next_significant( $tokens, $open );
+
+        if ( null === $literal || ! is_array( $tokens[ $literal ] ) || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $literal ][0] ) {
+            return '';
+        }
+
+        // The literal must BE the whole first argument: `define( 'PRE' . $suffix, 1 )` would
+        // otherwise register 'PRE' and hide a genuinely top-level define( 'PRE', ... ).
+        $separator = $this->next_significant( $tokens, $literal );
+
+        if ( null === $separator || ',' !== $tokens[ $separator ] ) {
+            return '';
+        }
+
+        return trim( $tokens[ $literal ][1], "'"" );
+    }
+
+    /**
+     * Whether every enclosing condition is a `defined( 'FOO' )` test on the constant
+     * itself, i.e. the define() is only wrapped in its own idempotency guard and therefore
+     * applies unconditionally.
+     *
+     * The test is deliberately narrow: merely mentioning the name is not enough, or
+     * `if ( getenv( 'WP_DEBUG' ) === 'yes' ) { define( 'WP_DEBUG', true ); }` — a genuinely
+     * conditional define — would be waved through as global config. It is not exhaustive
+     * either: a self-guard ANDed with another test (`if ( ! defined( 'FS_METHOD' ) && ! WP_CLI )`)
+     * still counts as a guard, which under-detects in the same direction as before this change.
+     *
+     * @param array  $headers
+     * @param string $name
+     *
+     * @return bool
+     */
+    protected function guards_itself( $headers, $name ) {
+        if ( empty( $headers ) ) {
+            return true;
+        }
+
+        $pattern = '/bdefineds*(s*['"]' . preg_quote( $name, '/' ) . '['"]s*)/';
+
+        foreach ( $headers as $header ) {
+            if ( ! preg_match( $pattern, $header ) ) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
     public function __construct( array $constants = [], $is_cli = false, $read_only = false ) {
         $file = ABSPATH . 'wp-config.php';
         if ( ! file_exists( $file ) ) {
@@ -60,8 +497,10 @@
             'wp-config' => [],
         ];

+        $scoped = $this->is_cli ? [] : $this->scoped_constants( $this->wp_config_src );
+
         foreach ( $this->wp_configs['constant'] as $constant => $data ) {
-            if ( ! $this->is_cli && ( preg_match( '/[a-z]/', $constant ) || in_array( $constant, $this->blacklisted, true ) ) ) {
+            if ( ! $this->is_cli && ( preg_match( '/[a-z]/', $constant ) || $this->is_blacklisted( $constant ) || isset( $scoped[ $constant ] ) ) ) {
                 continue;
             }

@@ -99,12 +538,23 @@
             $args['placement'] = 'after';
         }

+        $scoped  = $this->is_cli ? [] : $this->scoped_constants( $content );
+        $skipped = [];
+
         foreach ( $this->config_data as $key => $value ) {
             if ( empty( $key ) ) {
                 continue;
             }

-            if ( ! $this->is_cli && ( preg_match( '/[a-z]/', $key ) || in_array( $key, $this->blacklisted, true ) ) ) {
+            if ( ! $this->is_cli && preg_match( '/[a-z]/', $key ) ) {
+                // Not a constant name we ever manage — a malformed key, not a protection.
+                continue;
+            }
+
+            if ( ! $this->is_cli && ( $this->is_blacklisted( $key ) || isset( $scoped[ $key ] ) ) ) {
+                // Report what was deliberately not written. The caller posts the whole constant
+                // map back, so without this a protected constant looks saved and silently reverts.
+                $skipped[] = $key;
                 continue;
             }

@@ -142,7 +592,13 @@
             }
         }

-        return [ 'success' => true ];
+        $response = [ 'success' => true ];
+
+        if ( ! empty( $skipped ) ) {
+            $response['skipped'] = $skipped;
+        }
+
+        return $response;
     }

     public function delete() {
@@ -152,7 +608,24 @@
             throw new Exception( 'No constants provided!' );
         }

+        $scoped = [];
+
+        if ( ! $this->is_cli ) {
+            $content = file_get_contents( $this->wp_config_path );
+            $scoped  = $this->scoped_constants( $content );
+        }
+
+        $skipped = [];
+
         foreach ( $constants as $constant ) {
+            // Same protection get()/set() apply: the Config Manager never removes a
+            // blacklisted (platform-managed) constant, nor one that only exists inside a
+            // conditional block it was never able to show the caller.
+            if ( ! $this->is_cli && ( $this->is_blacklisted( $constant ) || isset( $scoped[ $constant ] ) ) ) {
+                $skipped[] = $constant;
+                continue;
+            }
+
             try {
                 $this->remove( 'constant', $constant );
             } catch ( Exception $e ) {
@@ -160,6 +633,12 @@
             }
         }

-        return [ 'success' => true ];
+        $response = [ 'success' => true ];
+
+        if ( ! empty( $skipped ) ) {
+            $response['skipped'] = $skipped;
+        }
+
+        return $response;
     }
 }
 No newline at end of file

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.