Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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__ ) );