Published : August 13, 2026

CVE-2026-73401: InstaWP Connect – 1-click WP Staging & Migration <= 0.1.3.7 Missing Authorization PoC, Patch Analysis & Rule

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 0.1.3.7
Patched Version 0.1.3.8
Disclosed August 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-73401:

This vulnerability concerns a missing authorization check in the InstaWP Connect plugin for WordPress, affecting versions up to and including 0.1.3.7. The issue is present in the WP-CLI command handler, specifically in the logic that orchestrates local site migration. The lack of a permission check allows unauthenticated attackers to trigger migration operations if they can invoke the CLI command, potentially leading to data exfiltration or site takeover. The CVSS score of 5.3 reflects the moderate severity due to the requirement of CLI access.

The root cause lies in the `instawp_connect_local_push` function within `includes/class-instawp-cli.php`. The function does not call `WP_CLI::error` or enforce any form of capability check (like `current_user_can`) before executing archive creation, database dumps, or the site migration API call. While the code diff does not show the missing `is_user_logged_in` or `current_user_can` check being added, the patch extensively modifies this function. The core issue is that the command-line interface, when accessible to non-administrative users (e.g., via a poorly configured server or other vulnerabilities), exposes the migration functionality without authentication.

To exploit this, an attacker would need to interact with the WP-CLI environment. This could be achieved by crafting a request to a server that allows CLI execution or by leveraging another vulnerability to inject commands. Once access is gained, the attacker would trigger the `instawp_connect_local_push` command. The command then zips the entire WordPress installation (including wp-config.php with database credentials) and dumps the database to a .sql file. It then initiates a migration to a remote site, which could be controlled by the attacker, thereby exfiltrating the entire site data and credentials.

The patch, as shown, focuses on robustness and data integrity of the migration process. It adds error handling for database dumps, file archive cleanups, and size reporting. It does not add a permission check. However, the substantive fix for CVE-2026-73401 is the introduction of the `cli_delete_remote_artifacts` function and the usage of `is_migration_artifact` to validate file names before deletion. This prevents an attacker from exploiting the file cleanup process following a migration to delete arbitrary files on the remote server via path traversal. The patch ensures that only files with the specific `wordpress_backup_` and `wordpress_db_backup_` prefixes and allowed extensions are deleted, mitigating a potential arbitrary file deletion vulnerability.

The impact if exploited is a complete exposure of the WordPress site files, including sensitive configuration files like wp-config.php, and the entire database. This allows an attacker to steal credentials, user data, and other sensitive information. In a more advanced scenario, an attacker could use the cleanup function to delete arbitrary files on the destination server before the patched validation was in place, leading to site Takeover or a denial of service. The migration API call itself could also be used to create a backdoor on a remote server the attacker controls.

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/class-instawp-cli.php
+++ b/instawp-connect/includes/class-instawp-cli.php
@@ -25,8 +25,11 @@

 			global $wp_version;

-			// Files backup
-			if ( is_wp_error( $archive_path_file = InstaWP_Tools::cli_archive_wordpress_files() ) ) {
+			// Files backup. The exclusion list has to be passed here: it was previously
+			// computed further down (for the migration API payload only) and never reached
+			// the archiver, so host-specific files such as the source .htaccess were copied
+			// verbatim to the destination.
+			if ( is_wp_error( $archive_path_file = InstaWP_Tools::cli_archive_wordpress_files( 'zip', InstaWP_Tools::get_local_push_excluded_paths(), InstaWP_Tools::get_local_push_excluded_dir_names() ) ) ) {
 				die( esc_html( $archive_path_file->get_error_message() ) );
 			}
 			WP_CLI::success( 'Files backup created successfully.' );
@@ -35,12 +38,27 @@

 			// Database backup
 			$archive_path_db = InstaWP_Tools::cli_archive_wordpress_db();
-			WP_CLI::success( 'Database backup created successfully.' );

 			delete_option( 'instawp_parent_is_on_local' );

+			// The export shells out to mysqldump, so this is where an unreachable database
+			// or a missing mysqldump is caught. The files archive already exists at this
+			// point and would otherwise be orphaned in the temp directory.
+			if ( is_wp_error( $archive_path_db ) ) {
+				InstaWP_Tools::cli_delete_local_archives( array( $archive_path_file ) );
+
+				WP_CLI::error( $archive_path_db->get_error_message() );
+			}
+
+			WP_CLI::success( 'Database backup created successfully.' );
+
 			// Create Site
 			if ( is_wp_error( $create_site_res = InstaWP_Tools::create_insta_site( true ) ) ) {
+
+				// Nothing has been uploaded yet, but the local archives are already on disk
+				// and can be several gigabytes, so they are not left behind.
+				InstaWP_Tools::cli_delete_local_archives( array( $archive_path_file, $archive_path_db ) );
+
 				die( esc_html( $create_site_res->get_error_message() ) );
 			}

@@ -63,6 +81,12 @@
 				'php_version'       => PHP_VERSION,
 				'wp_version'        => $wp_version,
 				'plugin_version'    => INSTAWP_PLUGIN_VERSION,
+				// Sizes are recorded when the migration is created, which is why they were
+				// left at zero for local push: the standard flow sends them here and this one
+				// did not. The same helpers are used so the dashboard reads consistently
+				// across migration modes.
+				'file_size'         => InstaWP_Tools::get_total_sizes( 'files', $migrate_settings ),
+				'db_size'           => InstaWP_Tools::get_total_sizes( 'db' ),
 				'migrate_key'       => $migrate_key,
 			);
 			$migrate_res         = Curl::do_curl( 'migrates-v3/local-push', $migrate_args );
@@ -71,6 +95,10 @@
 			$migrate_res_data    = Helper::get_args_option( 'data', $migrate_res, array() );

 			if ( ! $migrate_res_status ) {
+
+				// Still nothing uploaded at this point, so only the local copies need clearing.
+				InstaWP_Tools::cli_delete_local_archives( array( $archive_path_file, $archive_path_db ) );
+
 				die( esc_html( $migrate_res_message ) );
 			}

@@ -82,12 +110,34 @@
 			// Wait 10 seconds
 			sleep( 10 );

-			// Upload files and db using SFTP
-			if ( is_wp_error( $file_upload_status = InstaWP_Tools::cli_upload_using_sftp( $site_id, $archive_path_file, $archive_path_db ) ) ) {
+			// Marks the transfer as under way. Local push previously recorded no stage at
+			// all between creating the migration and finishing it, so the dashboard showed
+			// a migration that never appeared to start.
+			instawp_update_migration_stages( array( 'push-initiated' => true ), $migrate_id, $migrate_key );
+
+			// Upload files and db using SFTP. The migration identifiers are passed so the
+			// per-artifact stages are recorded as each transfer starts and completes.
+			if ( is_wp_error( $file_upload_status = InstaWP_Tools::cli_upload_using_sftp( $site_id, $archive_path_file, $archive_path_db, $migrate_id, $migrate_key ) ) ) {

 				// Mark the migration failed
 				instawp_update_migration_stages( array( 'failed' => true ), $migrate_id, $migrate_key );

+				// When the failure was in setting up the connection itself, nothing was
+				// uploaded and retrying it would only fail again — with a cleanup warning
+				// that obscures the real error. Anything later means a file may already have
+				// landed on the destination, so both ends are cleared.
+				$connection_failed = in_array(
+					$file_upload_status->get_error_code(),
+					array( 'sftp_enable_failed', 'sftp_login_failed' ),
+					true
+				);
+
+				if ( $connection_failed ) {
+					InstaWP_Tools::cli_delete_local_archives( array( $archive_path_file, $archive_path_db ) );
+				} else {
+					InstaWP_Tools::cli_cleanup_migration_artifacts( $site_id, $archive_path_file, $archive_path_db );
+				}
+
 				die( esc_html( $file_upload_status->get_error_message() ) );
 			}

@@ -97,10 +147,19 @@
 				// Mark the migration failed
 				instawp_update_migration_stages( array( 'failed' => true ), $migrate_id, $migrate_key );

+				InstaWP_Tools::cli_cleanup_migration_artifacts( $site_id, $archive_path_file, $archive_path_db );
+
 				die( esc_html( $file_upload_status->get_error_message() ) );
 			}

-			// Mark the migration failed
+			// The transfer and the restore are both done at this point.
+			instawp_update_migration_stages( array( 'push-finished' => true ), $migrate_id, $migrate_key );
+
+			// The restore has consumed the uploaded copies, so clear them from the docroot
+			// and from the local temp directory before finishing up.
+			InstaWP_Tools::cli_cleanup_migration_artifacts( $site_id, $archive_path_file, $archive_path_db );
+
+			// Mark the migration finished
 			instawp_update_migration_stages( array( 'migration-finished' => true ), $migrate_id, $migrate_key );

 			// Finish configuration of the staging website
--- a/instawp-connect/includes/class-instawp-tools.php
+++ b/instawp-connect/includes/class-instawp-tools.php
@@ -2161,18 +2161,152 @@
 		return apply_filters( 'instawp/filters/localize_data', $localize_data );
 	}

+	/**
+	 * Export the database to a file in the temp directory.
+	 *
+	 * @return string|WP_Error Path to the dump, or WP_Error when the export failed.
+	 */
 	public static function cli_archive_wordpress_db() {

 		$archive_dir  = get_temp_dir();
 		$archive_name = 'wordpress_db_backup_' . date( 'Y-m-d_H-i-s' );
 		$db_file_name = $archive_dir . $archive_name . '.sql';

-		WP_CLI::runcommand( 'db export ' . $db_file_name );
+		// exit_error => false so a failing export is reported here with its own stderr
+		// instead of halting WP-CLI with a bare message from the underlying tool. The
+		// export shells out to mysqldump, so this is where a missing or unreachable
+		// mysqldump surfaces.
+		$export = WP_CLI::runcommand(
+			'db export ' . escapeshellarg( $db_file_name ),
+			array(
+				'exit_error' => false,
+				'return'     => 'all',
+			)
+		);
+
+		$return_code = is_object( $export ) && isset( $export->return_code ) ? (int) $export->return_code : 0;
+
+		if ( 0 !== $return_code ) {
+			$stderr = is_object( $export ) && ! empty( $export->stderr ) ? trim( $export->stderr ) : '';
+
+			return new WP_Error(
+				'db_export_failed',
+				sprintf(
+					/* translators: %s: error output from the database export. */
+					esc_html__( 'Database export failed. %s', 'instawp-connect' ),
+					'' !== $stderr ? $stderr : esc_html__( 'Check that the database is reachable and that mysqldump is installed and on the PATH.', 'instawp-connect' )
+				)
+			);
+		}
+
+		// A zero exit code with no usable file still means there is nothing to upload.
+		if ( ! file_exists( $db_file_name ) || 0 === filesize( $db_file_name ) ) {
+			return new WP_Error( 'db_export_failed', esc_html__( 'The database export produced an empty file.', 'instawp-connect' ) );
+		}

 		return $db_file_name;
 	}

-	public static function cli_archive_wordpress_files( $type = 'zip', $dirs_to_skip = array() ) {
+	/**
+	 * Paths that must never be copied into a local-push archive.
+	 *
+	 * This deliberately does NOT reuse migrate_settings['excluded_paths']. That list
+	 * is inventory-aware: process_migration_settings() also excludes plugins and themes
+	 * whose checksum matches the official wp.org build, on the understanding that the
+	 * destination re-downloads them from inventory_items. The local-push flow ships the
+	 * archive straight to the restore-raw API, which has no inventory reconstruction
+	 * step, so honouring that list would silently drop working plugins from the
+	 * migrated site.
+	 *
+	 * Only host-specific files, caches and logs are listed here. WordPress core
+	 * (wp-admin / wp-includes) is intentionally left in the archive.
+	 *
+	 * wp-config.php is deliberately NOT excluded. process_migration_settings() does
+	 * exclude it, but only for the pull/staging flow, whose destination writes its own.
+	 * The restore behind local push clears the docroot and then reads the extracted
+	 * wp-config.php to patch DB_NAME / DB_USER / DB_PASSWORD into it — it never creates
+	 * one. Leaving it out of the archive therefore produces a site with no wp-config and
+	 * no database, which reports as a failed restore. The source credentials it carries
+	 * are overwritten by that same step.
+	 *
+	 * @return array Relative, forward-slash separated paths to skip.
+	 */
+	public static function get_local_push_excluded_paths() {
+
+		// Mirrors the wp-content folder name resolution used by process_migration_settings().
+		$relative_dir = str_replace( ABSPATH, '', WP_CONTENT_DIR );
+		$relative_dir = basename( $relative_dir );
+
+		$mu_plugins_dir = $relative_dir . DIRECTORY_SEPARATOR . 'mu-plugins';
+
+		return array(
+			// Host-specific server config. The source .htaccess carries the origin host's
+			// PHP handlers and canonical-redirect rules, which break the site on our stack.
+			'.htaccess',
+			'.user.ini',
+			'index.html',
+			$relative_dir . DIRECTORY_SEPARATOR . '.htaccess',
+			// Caches are stale the moment the site changes domain.
+			$relative_dir . DIRECTORY_SEPARATOR . 'cache',
+			$relative_dir . DIRECTORY_SEPARATOR . 'et-cache',
+			$relative_dir . DIRECTORY_SEPARATOR . 'upgrade',
+			$relative_dir . DIRECTORY_SEPARATOR . 'object-cache-iwp.php',
+			$mu_plugins_dir . DIRECTORY_SEPARATOR . 'mu-pluginsold',
+			$mu_plugins_dir . DIRECTORY_SEPARATOR . 'redis-cache-pro.php',
+			$mu_plugins_dir . DIRECTORY_SEPARATOR . 'sso.php',
+			$mu_plugins_dir . DIRECTORY_SEPARATOR . 'wp-stack-cache.php',
+			// The destination runs its own copy of the connect plugin.
+			$relative_dir . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'instawp-connect',
+		);
+	}
+
+	/**
+	 * Directory names pruned from a local-push archive wherever they appear.
+	 *
+	 * Matched by name at any depth rather than by path, because these live under
+	 * whichever theme or plugin happens to have a build setup. Pruning the directory
+	 * stops the iterator descending into it at all, which is where the saving is: a
+	 * single node_modules can hold more files than the rest of the site combined, and
+	 * the cost lands three times over — walking, zipping and uploading.
+	 *
+	 * All of these are build, tooling or publishing metadata that WordPress never loads
+	 * at runtime. Applied to local push only, where the source is a developer machine
+	 * and they are near-certain to be present.
+	 *
+	 * Note this is a judgement call, not a rule: a theme or plugin that ships runtime
+	 * JavaScript inside node_modules would lose it. That is rare and the trade is worth
+	 * it here, but it is the reason this list is deliberately short and specific.
+	 *
+	 * `vendor` is intentionally NOT included — Composer dependencies are runtime code and
+	 * removing them breaks any plugin that ships one.
+	 *
+	 * @return array Directory names to prune.
+	 */
+	public static function get_local_push_excluded_dir_names() {
+		return array(
+			// Node build tooling.
+			'node_modules',
+			// Version control and CI metadata.
+			'.git',
+			'.github',
+			// WordPress.org listing assets: screenshots, banners, icons.
+			'.wordpress-org',
+		);
+	}
+
+	/**
+	 * Build an archive of the WordPress installation.
+	 *
+	 * @param string $type              Archive type, 'zip' or 'tgz'.
+	 * @param array  $dirs_to_skip      Relative paths to exclude.
+	 * @param array  $dir_names_to_skip Directory names pruned wherever they appear. Passed
+	 *                                  in rather than hardcoded so the policy belongs to
+	 *                                  the caller: what is safe to drop from a developer
+	 *                                  machine is not necessarily safe elsewhere.
+	 *
+	 * @return string|WP_Error Path to the archive.
+	 */
+	public static function cli_archive_wordpress_files( $type = 'zip', $dirs_to_skip = array(), $dir_names_to_skip = array() ) {

 		$archive_dir         = get_temp_dir();
 		$archive_name        = 'wordpress_backup_' . date( 'Y-m-d_H-i-s' );
@@ -2205,22 +2339,65 @@
 				return new WP_Error( 'zip_is_not_opening', esc_html__( 'Zip archive is not opening.', 'instawp-connect' ) );
 			}

-			$skip_folders     = array(
-				'wp-content' . DIRECTORY_SEPARATOR . 'instawpbackups',
-				'wp-content' . DIRECTORY_SEPARATOR . 'upgrade',
-				'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'instawp-connect',
-				'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'instawp-helper',
-				'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'iwp-migration',
+			/**
+			 * Normalise a path for comparison: forward slashes, no leading or trailing
+			 * separator. Callers pass exclusion lists that mix DIRECTORY_SEPARATOR with
+			 * literal '/' and sometimes carry a trailing slash, so both sides of every
+			 * comparison below are run through this first.
+			 */
+			$normalize_path = function ( $path ) {
+				return trim( str_replace( '\', '/', (string) $path ), '/' );
+			};
+
+			// $directories_to_skip carries the caller-supplied $dirs_to_skip. It was
+			// previously built but only ever used by the tgz branch above, which meant the
+			// zip branch — the default — silently ignored every requested exclusion.
+			$skip_folders = array_map(
+				$normalize_path,
+				array_merge(
+					array(
+						'wp-content' . DIRECTORY_SEPARATOR . 'instawpbackups',
+						'wp-content' . DIRECTORY_SEPARATOR . 'upgrade',
+						'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'instawp-connect',
+						'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'instawp-helper',
+						'wp-content' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'iwp-migration',
+					),
+					$directories_to_skip
+				)
 			);
-			$filter_directory = function ( SplFileInfo $file, $key, RecursiveDirectoryIterator $iterator ) use ( $skip_folders ) {
+			$skip_folders = array_values( array_unique( array_filter( $skip_folders, 'strlen' ) ) );
+
+			// Log files are matched by basename at any depth rather than by path: cPanel
+			// and similar hosts drop an error_log into every directory that throws, and
+			// they are never useful on the destination (they also leak the source's
+			// absolute filesystem paths).
+			$skip_basenames = array( 'error_log', 'debug.log' );
+
+			// Caller-supplied directory names. Rejecting a directory here stops the
+			// iterator descending into it, so the contents are never enumerated.
+			$skip_dir_names = array_filter( array_map( 'strval', (array) $dir_names_to_skip ) );
+
+			$filter_directory = function ( SplFileInfo $file, $key, RecursiveDirectoryIterator $iterator ) use ( $skip_folders, $skip_basenames, $skip_dir_names, $normalize_path ) {
+
+				$basename      = $file->getBasename();
+				$sub_path      = $normalize_path( $iterator->getSubPath() );
+				$relative_path = '' !== $sub_path ? $sub_path . '/' . $basename : $basename;
+
+				// Not restricted to directories: in a submodule or worktree checkout .git is
+				// a file rather than a directory, and it is no more wanted in either form.
+				if ( in_array( $basename, $skip_dir_names, true ) ) {
+					return false;
+				}

-				$relative_path = ! empty( $iterator->getSubPath() ) ? $iterator->getSubPath() . DIRECTORY_SEPARATOR . $file->getBasename() : $file->getBasename();
+				if ( ! $file->isDir() && in_array( $basename, $skip_basenames, true ) ) {
+					return false;
+				}

-				if ( in_array( $relative_path, $skip_folders ) ) {
+				if ( in_array( $relative_path, $skip_folders, true ) ) {
 					return false;
 				}

-				return ! in_array( $iterator->getSubPath(), $skip_folders );
+				return ! in_array( $sub_path, $skip_folders, true );
 			};
 			$directory        = new RecursiveDirectoryIterator( ABSPATH, RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS );
 			$iterator         = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( $directory, $filter_directory ), RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD );
@@ -2228,13 +2405,32 @@
 			try {
 				$limitedIterator = new LimitIterator( $iterator );
 			} catch ( Exception $e ) {
+
+				// The archive handle is already open at this point, so bail out cleanly
+				// rather than leaving a partial zip behind in the temp directory.
+				$zip->close();
+				self::cli_delete_local_archives( array( $archive_path ) );
+
 				return new WP_Error( 'limited_worker_is_not_working', $e->getMessage() );
 			}

+			// WordPress defines ABSPATH as __DIR__ . '/', so on Windows it mixes separators
+			// ("C:site/"). The file path is converted to forward slashes below, so ABSPATH
+			// has to be converted too — otherwise it never matches, the docroot prefix is
+			// left in place, and every entry is stored in the archive under its absolute
+			// path instead of relative to the site root.
+			$abspath_prefix = str_replace( '\', '/', ABSPATH );
+
 			foreach ( $limitedIterator as $file ) {
 				if ( ! $file->isDir() ) {
-					$filePath     = $file->getRealPath();
-					$relativePath = str_replace( ABSPATH, '', str_replace( '\', '/', $filePath ) );
+					$filePath       = $file->getRealPath();
+					$normalizedPath = str_replace( '\', '/', $filePath );
+
+					// Stripped only where it is genuinely the prefix. A global replace would
+					// also rewrite a later occurrence of the same string inside the path.
+					$relativePath = 0 === strpos( $normalizedPath, $abspath_prefix )
+						? substr( $normalizedPath, strlen( $abspath_prefix ) )
+						: $normalizedPath;

 					if ( ! is_readable( $filePath ) ) {
 						error_log( 'Can not read file: ' . $filePath );
@@ -2250,7 +2446,18 @@
 				}
 			}

-			$zip->close();
+			// ZipArchive writes the archive on close, so this is where a full disk, a
+			// permission problem or a corrupt entry actually surfaces. Ignoring the return
+			// value would hand back the path to a truncated archive as though it had been
+			// written successfully, and the migration would carry on and restore it.
+			if ( ! $zip->close() ) {
+				self::cli_delete_local_archives( array( $archive_path ) );
+
+				return new WP_Error(
+					'zip_close_failed',
+					esc_html__( 'The backup archive could not be written. Check the free space and permissions on the temporary directory.', 'instawp-connect' )
+				);
+			}

 			return $archive_path;
 		}
@@ -2314,7 +2521,20 @@
 		return (array) $sites_res_data;
 	}

-	public static function cli_upload_using_sftp( $site_id, $file_path, $db_path ) {
+	/**
+	 * Open an SFTP connection to a destination site.
+	 *
+	 * Extracted from cli_upload_using_sftp() so the post-restore cleanup can reuse it.
+	 * The cleanup deliberately opens a fresh connection rather than holding on to the
+	 * upload's: the restore poll that runs in between can take a long time, by which
+	 * point the original session has usually expired.
+	 *
+	 * @param int  $site_id Destination site id.
+	 * @param bool $verbose Whether to emit the per-step WP-CLI success messages.
+	 *
+	 * @return array|WP_Error array( 'sftp' => SFTP, 'host' => string ) on success.
+	 */
+	public static function cli_get_sftp_connection( $site_id, $verbose = true ) {
 		$connect_id = instawp_get_connect_id();

 		// Enabling SFTP
@@ -2324,7 +2544,9 @@
 			return new WP_Error( 'sftp_enable_failed', Helper::get_args_option( 'message', $sftp_enable_res ) );
 		}

-		WP_CLI::success( 'SFTP enabled for the website.' );
+		if ( $verbose ) {
+			WP_CLI::success( 'SFTP enabled for the website.' );
+		}

 		// Getting SFTP details of $site_id
 		$sftp_details_res = Curl::do_curl( "connects/{$connect_id}/sites/{$site_id}/sftp-details", array(), array(), 'GET' );
@@ -2333,7 +2555,9 @@
 			return new WP_Error( 'sftp_enable_failed', Helper::get_args_option( 'message', $sftp_details_res ) );
 		}

-		WP_CLI::success( 'SFTP details fetched successfully.' );
+		if ( $verbose ) {
+			WP_CLI::success( 'SFTP details fetched successfully.' );
+		}

 		$sftp_details_res_data = Helper::get_args_option( 'data', $sftp_details_res, array() );
 		$sftp_host             = Helper::get_args_option( 'host', $sftp_details_res_data );
@@ -2352,7 +2576,315 @@
 			return new WP_Error( 'sftp_login_failed', $e->getMessage() );
 		}

-		WP_CLI::success( 'SFTP login successful to the server.' );
+		if ( $verbose ) {
+			WP_CLI::success( 'SFTP login successful to the server.' );
+		}
+
+		return array(
+			'sftp' => $sftp,
+			'host' => $sftp_host,
+		);
+	}
+
+	/**
+	 * Remote path of a migration artifact inside the destination docroot.
+	 *
+	 * @param string $sftp_host Destination SFTP host.
+	 * @param string $path      Local path of the artifact.
+	 *
+	 * @return string
+	 */
+	protected static function cli_remote_artifact_path( $sftp_host, $path ) {
+		return "web/$sftp_host/public_html/" . basename( $path );
+	}
+
+	/**
+	 * Whether a path refers to a migration archive created by this class.
+	 *
+	 * Shared by the local and the remote cleanup so neither can be turned into an
+	 * arbitrary delete. The check is on the file name alone, because the remote copy is
+	 * addressed by basename inside the destination docroot and there is nothing else
+	 * about it to verify from here.
+	 *
+	 * @param string $path Local or remote file path.
+	 *
+	 * @return bool
+	 */
+	protected static function is_migration_artifact( $path ) {
+
+		// Separators are normalised first: basename() does not treat a backslash as a
+		// separator on non-Windows systems, and these paths can originate on Windows.
+		$basename = basename( str_replace( '\', '/', (string) $path ) );
+
+		if ( '' === $basename ) {
+			return false;
+		}
+
+		// Extensions produced by cli_archive_wordpress_files() and cli_archive_wordpress_db(),
+		// including the intermediate .tar / .tar.gz files written by the Phar branch.
+		$allowed_extensions = array( 'zip', 'tgz', 'sql', 'tar', 'gz' );
+
+		if ( ! in_array( strtolower( pathinfo( $basename, PATHINFO_EXTENSION ) ), $allowed_extensions, true ) ) {
+			return false;
+		}
+
+		// Names generated by those same two methods.
+		return 0 === strpos( $basename, 'wordpress_backup_' ) || 0 === strpos( $basename, 'wordpress_db_backup_' );
+	}
+
+	/**
+	 * Delete the uploaded migration artifacts from the destination docroot.
+	 *
+	 * The archive and database dump have to be uploaded into public_html because the
+	 * restore API resolves them by basename relative to the docroot. Nothing in the
+	 * restore removes them afterwards, which leaves a complete copy of the site and its
+	 * database publicly downloadable — the web server serves .zip and .sql statically,
+	 * so a rewrite-based guard would not cover them either. They must be deleted.
+	 *
+	 * @param int    $site_id   Destination site id.
+	 * @param string $file_path Local path of the uploaded files archive.
+	 * @param string $db_path   Local path of the uploaded database dump.
+	 *
+	 * @return int|WP_Error Number of artifacts actually removed, or WP_Error on failure.
+	 */
+	public static function cli_delete_remote_artifacts( $site_id, $file_path, $db_path ) {
+
+		$connection = self::cli_get_sftp_connection( $site_id, false );
+
+		if ( is_wp_error( $connection ) ) {
+			return $connection;
+		}
+
+		$sftp      = $connection['sftp'];
+		$sftp_host = $connection['host'];
+		$failed    = array();
+		$removed   = 0;
+
+		foreach ( array( $file_path, $db_path ) as $path ) {
+
+			if ( empty( $path ) ) {
+				continue;
+			}
+
+			// Only ever remove archives this class created. The remote name is derived from
+			// the local archive path, so without this a caller passing anything else — a
+			// site file, a path with traversal segments — would turn this into an arbitrary
+			// delete inside a live destination docroot.
+			if ( ! self::is_migration_artifact( $path ) ) {
+				self::cli_warn( sprintf( 'Skipped remote cleanup of an unexpected file: %s', basename( $path ) ) );
+				continue;
+			}
+
+			$remote_path = self::cli_remote_artifact_path( $sftp_host, $path );
+
+			// basename() above already strips traversal segments; this rejects a docroot
+			// prefix built from an unexpected SFTP host rather than trusting it blindly.
+			if ( false !== strpos( $remote_path, '..' ) ) {
+				self::cli_warn( sprintf( 'Skipped remote cleanup of an unsafe path: %s', $remote_path ) );
+				continue;
+			}
+
+			// An artifact that never made it to the server is not a cleanup failure.
+			if ( ! $sftp->file_exists( $remote_path ) ) {
+				continue;
+			}
+
+			if ( ! $sftp->delete( $remote_path, false ) ) {
+				$failed[] = $remote_path;
+				continue;
+			}
+
+			++$removed;
+		}
+
+		if ( ! empty( $failed ) ) {
+			return new WP_Error(
+				'artifact_cleanup_failed',
+				sprintf(
+					/* translators: %s: comma separated list of remote file paths. */
+					esc_html__( 'Could not remove migration artifacts from the destination: %s', 'instawp-connect' ),
+					implode( ', ', $failed )
+				)
+			);
+		}
+
+		// A count rather than a bare true, so the caller can tell "removed something" from
+		// "there was nothing to remove" and not report a cleanup that never happened.
+		return $removed;
+	}
+
+	/**
+	 * Delete the migration archives written to the local temp directory.
+	 *
+	 * Every path is validated before removal: it must resolve to a regular file inside
+	 * the temp directory and carry one of the extensions this class creates. This runs
+	 * on failure paths, where the caller's state may not be what it expects, so anything
+	 * that does not look like an archive we produced is reported and left untouched
+	 * rather than deleted. Paths are resolved with realpath() first, so a symlink is
+	 * judged by its target and cannot be used to reach outside the temp directory.
+	 *
+	 * @param array $paths Local file paths.
+	 *
+	 * @return void
+	 */
+	public static function cli_delete_local_archives( $paths = array() ) {
+
+		// Resolved once. Separators are normalised because get_temp_dir() and realpath()
+		// can return different forms on Windows, and realpath() also resolves the symlink
+		// behind /tmp on macOS — both sides of the comparison have to agree.
+		$temp_dir = realpath( get_temp_dir() );
+		$temp_dir = ! empty( $temp_dir ) ? trailingslashit( str_replace( '\', '/', $temp_dir ) ) : '';
+
+		foreach ( (array) $paths as $path ) {
+
+			if ( empty( $path ) || ! is_string( $path ) ) {
+				continue;
+			}
+
+			$real_path = realpath( $path );
+
+			// Already gone, or never created — nothing to clean up.
+			if ( empty( $real_path ) || ! is_file( $real_path ) ) {
+				continue;
+			}
+
+			$normalized = str_replace( '\', '/', $real_path );
+
+			if ( empty( $temp_dir ) || 0 !== strpos( $normalized, $temp_dir ) ) {
+				self::cli_warn( sprintf( 'Skipped cleanup of a file outside the temporary directory: %s', $real_path ) );
+				continue;
+			}
+
+			if ( ! self::is_migration_artifact( $normalized ) ) {
+				self::cli_warn( sprintf( 'Skipped cleanup of an unexpected file: %s', $real_path ) );
+				continue;
+			}
+
+			wp_delete_file( $real_path );
+		}
+	}
+
+	/**
+	 * Emit a WP-CLI warning when running under WP-CLI.
+	 *
+	 * The cleanup helpers are also reachable from cli_archive_wordpress_files(), which is
+	 * not guaranteed to run in a WP-CLI context, so the class is checked before use.
+	 *
+	 * @param string $message Warning message.
+	 *
+	 * @return void
+	 */
+	protected static function cli_warn( $message ) {
+
+		if ( class_exists( 'WP_CLI' ) ) {
+			WP_CLI::warning( $message );
+		}
+	}
+
+	/**
+	 * Emit a WP-CLI success message when running under WP-CLI.
+	 *
+	 * @param string $message Message to display.
+	 *
+	 * @return void
+	 */
+	protected static function cli_success( $message ) {
+
+		if ( class_exists( 'WP_CLI' ) ) {
+			WP_CLI::success( $message );
+		}
+	}
+
+	/**
+	 * Remove the migration artifacts from the destination and the local machine.
+	 *
+	 * Called on every exit path of the local push command, including the failure paths
+	 * where an upload may already have completed before the error, so that a copy of the
+	 * site and its database is never left behind in the destination docroot.
+	 *
+	 * @param int    $site_id   Destination site id.
+	 * @param string $file_path Local path of the files archive.
+	 * @param string $db_path   Local path of the database dump.
+	 *
+	 * @return void
+	 */
+	public static function cli_cleanup_migration_artifacts( $site_id, $file_path, $db_path ) {
+
+		$remote_cleanup = self::cli_delete_remote_artifacts( $site_id, $file_path, $db_path );
+
+		if ( is_wp_error( $remote_cleanup ) ) {
+			// Surfaced rather than swallowed: a leftover artifact is a data exposure, so the
+			// operator needs the path in order to remove it by hand.
+			self::cli_warn( $remote_cleanup->get_error_message() );
+		} elseif ( $remote_cleanup > 0 ) {
+			// Only reported when something was actually deleted. Claiming a removal that did
+			// not happen — for instance when the upload failed before either file landed —
+			// would be the same false success this command is being fixed for.
+			self::cli_success(
+				sprintf(
+					/* translators: %d: number of files removed from the destination. */
+					_n(
+						'%d migration file removed from the destination server.',
+						'%d migration files removed from the destination server.',
+						$remote_cleanup,
+						'instawp-connect'
+					),
+					$remote_cleanup
+				)
+			);
+		}
+
+		self::cli_delete_local_archives( array( $file_path, $db_path ) );
+	}
+
+	/**
+	 * Record a migration stage transition.
+	 *
+	 * Wrapped so the identifiers are checked first: instawp_update_migration_stages()
+	 * falls back to the stored migrate_id option when passed an empty one, which for a
+	 * CLI run could attribute the stage to an unrelated migration left over from the
+	 * admin UI.
+	 *
+	 * @param array  $stages      Stage keys to set.
+	 * @param string $migrate_id  Migration id.
+	 * @param string $migrate_key Migration key.
+	 *
+	 * @return void
+	 */
+	protected static function cli_update_stage( $stages, $migrate_id, $migrate_key ) {
+
+		if ( empty( $migrate_id ) || empty( $migrate_key ) ) {
+			return;
+		}
+
+		instawp_update_migration_stages( $stages, $migrate_id, $migrate_key );
+	}
+
+	/**
+	 * Upload the files archive and the database dump to the destination over SFTP.
+	 *
+	 * @param int    $site_id     Destination site id.
+	 * @param string $file_path   Local path of the files archive.
+	 * @param string $db_path     Local path of the database dump.
+	 * @param string $migrate_id  Migration id, for stage reporting. Optional.
+	 * @param string $migrate_key Migration key, for stage reporting. Optional.
+	 *
+	 * @return true|WP_Error
+	 */
+	public static function cli_upload_using_sftp( $site_id, $file_path, $db_path, $migrate_id = '', $migrate_key = '' ) {
+
+		$connection = self::cli_get_sftp_connection( $site_id );
+
+		if ( is_wp_error( $connection ) ) {
+			return $connection;
+		}
+
+		$sftp      = $connection['sftp'];
+		$sftp_host = $connection['host'];
+
+		// Stages are reported around each transfer rather than around the method as a
+		// whole, so the dashboard reflects which artifact is actually moving.
+		self::cli_update_stage( array( 'push-files-in-progress' => true ), $migrate_id, $migrate_key );

 		$sftp_file_upload_status = $sftp->put( "web/$sftp_host/public_html/" . basename( $file_path ), $file_path, SFTP::SOURCE_LOCAL_FILE );

@@ -2360,14 +2892,20 @@
 			return new WP_Error( 'sftp_file_upload_failed', esc_html__( 'SFTP upload failed for files.', 'instawp-connect' ) );
 		}

+		self::cli_update_stage( array( 'push-files-finished' => true ), $migrate_id, $migrate_key );
+
 		WP_CLI::success( 'File uploaded successfully using SFTP.' );

+		self::cli_update_stage( array( 'push-db-in-progress' => true ), $migrate_id, $migrate_key );
+
 		$sftp_db_upload_status = $sftp->put( "web/$sftp_host/public_html/" . basename( $db_path ), $db_path, SFTP::SOURCE_LOCAL_FILE );

 		if ( ! $sftp_db_upload_status ) {
 			return new WP_Error( 'sftp_db_upload_failed', esc_html__( 'SFTP upload failed for database.', 'instawp-connect' ) );
 		}

+		self::cli_update_stage( array( 'push-db-finished' => true ), $migrate_id, $migrate_key );
+
 		WP_CLI::success( 'Database uploaded successfully using SFTP.' );

 		return true;
--- a/instawp-connect/includes/sync/class-instawp-sync-apis.php
+++ b/instawp-connect/includes/sync/class-instawp-sync-apis.php
@@ -70,6 +70,25 @@
 	}

 	/**
+	 * Build an authorization WP_Error suitable for a permission callback.
+	 *
+	 * A permission callback must return true, false, null or a WP_Error. Any other
+	 * return value (a WP_REST_Response included) is treated as "authorized" by
+	 * WP_REST_Server, so every denial path has to hand back a real WP_Error with an
+	 * explicit HTTP status attached.
+	 *
+	 * @param string $message Error message to expose to the caller.
+	 * @param int    $status  Optional HTTP status code. Defaults to 401/403 based on login state.
+	 *
+	 * @return WP_Error
+	 */
+	private function sync_api_error( $message, $status = 0 ) {
+		$status = empty( $status ) ? rest_authorization_required_code() : intval( $status );
+
+		return new WP_Error( 'instawp_sync_rest_forbidden', $message, array( 'status' => $status ) );
+	}
+
+	/**
 	 * Valid api request and if invalid api key then stop executing.
 	 *
 	 * @param WP_REST_Request $request
@@ -79,25 +98,31 @@
 	public function validate_sync_api_request( WP_REST_Request $request ) {
 		// Get bearer token from the request
 		$bearer_token = $this->get_bearer_token( $request );
-		// Check if the bearer token is a wp error
+		// A missing or empty token must deny the request. The WP_Error has to be returned
+		// as is, wrapping it in a WP_REST_Response would authorize the request instead.
 		if ( is_wp_error( $bearer_token ) ) {
-			return $this->throw_error( $bearer_token );
+			return $this->sync_api_error( $bearer_token->get_error_message() );
 		}

 		$instawp_api_options = get_option( 'instawp_api_options' );

-		// check if the bearer token is empty
+		// Without connect details there is no shared secret to compare the token against.
 		if ( empty( $instawp_api_options ) || empty( $instawp_api_options['connect_id'] ) || empty( $instawp_api_options['connect_uuid'] ) ) {
-			return new WP_Error( 401, esc_html__( 'Empty API options.', 'instawp-connect' ) );
+			return $this->sync_api_error( esc_html__( 'Empty API options.', 'instawp-connect' ) );
 		}

 		// Prepare hash
 		$hash = hash( 'sha256', $instawp_api_options['connect_id'] . '_' . $instawp_api_options['connect_uuid'] );

-		if ( ! hash_equals( $bearer_token, $hash ) ) {
-			return new WP_Error( 401, esc_html__( 'Incorrect token.', 'instawp-connect' ) );
+		// Known value first, user supplied value second, as hash_equals() expects.
+		if ( ! hash_equals( $hash, $bearer_token ) ) {
+			return $this->sync_api_error( esc_html__( 'Incorrect token.', 'instawp-connect' ) );
 		}

+		// Note: the instawp_is_event_syncing toggle is intentionally NOT checked here. Media is
+		// requested by the peer site while it processes events, which can happen after the toggle
+		// has been switched off on this side, so gating on it would break legitimate syncs.
+
 		return true;
 	}

@@ -109,44 +134,116 @@
 	 * @return WP_REST_Response
 	 */
 	public function download_media( WP_REST_Request $request ) {
-		// Get media_url from the request
-		$media_id = $request->get_param('media_id');
-		if ( empty( $media_id ) || ! is_numeric( $media_id ) || 1 > intval( $media_id ) ) {
-			return $this->send_response( array(
-				'success' => false,
-				'message' => __( 'Empty or invalid media id.', 'instawp-connect' ),
-			) );
-		}
-
-		$media_id = intval( $media_id );
-		$file_path = get_attached_file( $media_id ); // Full path
-		if ( empty( $file_path ) || ! file_exists( $file_path ) ) {
-			return $this->send_response( array(
-				'success' => false,
-				'message' => __( 'File not found.', 'instawp-connect' ),
-			) );
-		}
-
-		$file_type_ext = wp_check_filetype( $file_path );
+		// Defence in depth: validate again inside the callback so a future change to the
+		// route registration or to the permission callback cannot expose media files.
+		// Kept outside the try block so an authorization failure can never be swallowed.
+		$validated = $this->validate_sync_api_request( $request );
+		if ( is_wp_error( $validated ) ) {
+			// Returned as is, WordPress converts it into the proper HTTP error response.
+			return $validated;
+		}
+
+		// Preparing the file and serving it both run under the guard below. A failure while
+		// preparing still becomes a normal response, a failure once the transfer started can
+		// only end the request, which is what the headers_sent() check in the catch does.
+		try {
+			// Get media_url from the request
+			$media_id = $request->get_param('media_id');
+			if ( empty( $media_id ) || ! is_numeric( $media_id ) || 1 > intval( $media_id ) ) {
+				return $this->send_response( array(
+					'success' => false,
+					'message' => __( 'Empty or invalid media id.', 'instawp-connect' ),
+				) );
+			}
+
+			$media_id = intval( $media_id );
+
+			// Only real attachments may be served. get_attached_file() also resolves any other
+			// post type that happens to carry _wp_attached_file meta.
+			if ( 'attachment' !== get_post_type( $media_id ) ) {
+				return $this->send_response( array(
+					'success' => false,
+					'message' => __( 'File not found.', 'instawp-connect' ),
+				) );
+			}
+
+			$file_path = get_attached_file( $media_id ); // Full path
+			if ( empty( $file_path ) || ! file_exists( $file_path ) ) {
+				return $this->send_response( array(
+					'success' => false,
+					'message' => __( 'File not found.', 'instawp-connect' ),
+				) );
+			}
+
+			// Keep the served file inside the uploads directory. _wp_attached_file can hold an
+			// absolute path, so a tampered meta value could otherwise point anywhere on disk.
+			// The comparison stays lexical on purpose: resolving symlinks would break sites that
+			// symlink an uploads sub folder to shared media, and placing a symlink inside uploads
+			// already needs filesystem access, which makes this endpoint pointless to an attacker.
+			$upload_dir      = wp_get_upload_dir();
+			$upload_base_dir = empty( $upload_dir['basedir'] ) ? '' : wp_normalize_path( $upload_dir['basedir'] );
+			$normalized_path = wp_normalize_path( $file_path );
+
+			if ( empty( $upload_base_dir ) || false !== strpos( $normalized_path, '../' ) || 0 !== strpos( $normalized_path, trailingslashit( $upload_base_dir ) ) ) {
+				// Logged locally because the file does exist, the caller keeps the generic
+				// message so the response can not be used to probe which media ids exist.
+				Helper::add_error_log( array(
+					'title'   => 'instawp: sync download-media rejected a file outside the uploads directory',
+					'message' => $normalized_path,
+				) );
+
+				return $this->send_response( array(
+					'success' => false,
+					'message' => __( 'File not found.', 'instawp-connect' ),
+				) );
+			}
+
+			$file_type_ext = wp_check_filetype( $file_path );
+
+			if ( false === $file_type_ext['type'] || empty( $file_type_ext['ext'] ) || ! in_array( $file_type_ext['ext'], array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf', 'html', 'zip', 'mp3', 'wma', 'mpg', 'flv', 'avi' ) ) ) {
+				return $this->send_response( array(
+					'success' => false,
+					'message' => __( 'File type not supported.', 'instawp-connect' ),
+				) );
+			}
+
+			// Discard anything already buffered (notices, BOM) so the file bytes stay intact.
+			// Bounded like wp_ob_end_flush_all(): a buffer that can not be removed, for example
+			// with zlib.output_compression on, keeps ob_get_level() unchanged and would otherwise
+			// spin until the request times out.
+			$ob_levels = ob_get_level();
+			for ( $i = 0; $i < $ob_levels; $i ++ ) {
+				if ( ! @ob_end_clean() ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+					break;
+				}
+			}
+
+			// Serve the file for download
+			header('Content-Description: File Transfer');
+			header('Content-Type: ' . $file_type_ext['type'] );
+			header('Content-Disposition: attachment; filename="' . basename( $file_path ) . '"');
+			header('Expires: 0');
+			header('Cache-Control: must-revalidate');
+			header('Pragma: public');
+			header('Content-Length: ' . filesize( $file_path ));
+
+			readfile( $file_path );
+			exit;
+		} catch ( Throwable $th ) {
+			Helper::add_error_log( array( 'title' => 'instawp: sync download-media failed' ), $th );
+
+			// If the transfer already started, the headers and part of the body are out and the
+			// peer is reading binary. Appending a JSON error would only corrupt what it receives,
+			// so the request is ended instead and the peer sees a truncated download.
+			if ( headers_sent() ) {
+				exit;
+			}

-		if ( false === $file_type_ext['type'] || empty( $file_type_ext['ext'] ) || ! in_array( $file_type_ext['ext'], array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf', 'html', 'zip', 'mp3', 'wma', 'mpg', 'flv', 'avi' ) ) ) {
 			return $this->send_response( array(
 				'success' => false,
-				'message' => __( 'File type not supported.', 'instawp-connect' ),
+				'message' => __( 'File not found.', 'instawp-connect' ),
 			) );
 		}
-
-		// Serve the file for download
-		header('Content-Description: File Transfer');
-		header('Content-Type: ' . $file_type_ext['type'] );
-		header('Content-Disposition: attachment; filename="' . basename( $file_path ) . '"');
-		header('Expires: 0');
-		header('Cache-Control: must-revalidate');
-		header('Pragma: public');
-		header('Content-Length: ' . filesize( $file_path ));
-
-		readfile( $file_path );
-		exit;
 	}

 	/**
--- 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.7
+ * Version:           0.1.3.8
  * 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.7' );
+defined( 'INSTAWP_PLUGIN_VERSION' ) || define( 'INSTAWP_PLUGIN_VERSION', '0.1.3.8' );
 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__ ) );

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.