diff --git a/connector/emcp-connector.php b/connector/emcp-connector.php index a8624e4..e3efc3a 100644 --- a/connector/emcp-connector.php +++ b/connector/emcp-connector.php @@ -72,8 +72,11 @@ function emcp_connector_verify( string $canonical, string $signature ): bool { * packet -> 'packet|' . sha256 . '|' . transfer_id . '|' . index * finalize -> 'finalize|' . sha256 . '|' . transfer_id * job -> 'job|' . job_id - * pair/issue -> 'pair|issue|' - * verify -> 'verify|' + * pair/issue -> 'pair|issue|' . ts . '|' . nonce + * verify -> 'verify|' . ts . '|' . nonce + * The pair/issue + verify routes bind each signature to a per-request timestamp + * and single-use nonce so a captured signature cannot be replayed (see + * emcp_connector_nonce_fresh()). */ function emcp_connector_canonical( WP_REST_Request $request ): string { $params = $request->get_json_params(); @@ -93,18 +96,69 @@ function emcp_connector_canonical( WP_REST_Request $request ): string { return 'job|' . (string) $request['id']; } if ( false !== strpos( $route, '/pair/issue' ) ) { - return 'pair|issue|'; + return 'pair|issue|' . (string) $request->get_param( 'ts' ) . '|' . (string) $request->get_param( 'nonce' ); } if ( false !== strpos( $route, '/verify' ) ) { - return 'verify|'; + return 'verify|' . (string) $request->get_param( 'ts' ) . '|' . (string) $request->get_param( 'nonce' ); } return ''; } -/** Validate the X-EMCP-Signature header against the route canonical. */ +/** + * Validate the X-EMCP-Signature header against the route canonical. The + * pair/issue + verify routes additionally require a fresh, single-use + * ts/nonce so their (constant-shaped) signatures cannot be replayed. + */ function emcp_connector_authed( WP_REST_Request $request ) { $signature = isset( $_SERVER['HTTP_X_EMCP_SIGNATURE'] ) ? (string) $_SERVER['HTTP_X_EMCP_SIGNATURE'] : ''; - return emcp_connector_verify( emcp_connector_canonical( $request ), $signature ); + if ( ! emcp_connector_verify( emcp_connector_canonical( $request ), $signature ) ) { + return false; + } + $route = $request->get_route(); + if ( false !== strpos( $route, '/pair/issue' ) || false !== strpos( $route, '/verify' ) ) { + return emcp_connector_nonce_fresh( (string) $request->get_param( 'ts' ), (string) $request->get_param( 'nonce' ) ); + } + return true; +} + +/** + * Freshness + single-use gate for the pair/issue + verify signatures. + * + * Accepts a ts within +-300 s of server time and a nonce (>= 16 chars) that has + * not been seen before; records the nonce (hashed, 1 h expiry, capped) so a + * captured signature can never be replayed. + * + * @param string $ts Unix timestamp (seconds) the caller signed. + * @param string $nonce Caller-chosen single-use nonce. + * @return bool + */ +function emcp_connector_nonce_fresh( string $ts, string $nonce ): bool { + if ( '' === $ts || '' === $nonce || strlen( $nonce ) < 16 ) { + return false; + } + $now = time(); + if ( abs( (int) $ts - $now ) > 300 ) { + return false; + } + $key = hash( 'sha256', $ts . '|' . $nonce ); + $used = get_option( 'emcp_connector_nonces', array() ); + if ( ! is_array( $used ) ) { + $used = array(); + } + $used = array_values( array_filter( $used, static function ( $e ) use ( $now ) { + return is_array( $e ) && ( (int) ( $e['exp'] ?? 0 ) > $now ); + } ) ); + foreach ( $used as $e ) { + if ( is_array( $e ) && hash_equals( (string) $e['h'], $key ) ) { + return false; + } + } + $used[] = array( 'h' => $key, 'exp' => $now + 3600 ); + if ( count( $used ) > 500 ) { + $used = array_slice( $used, -500 ); + } + update_option( 'emcp_connector_nonces', $used, false ); + return true; } /** @@ -208,14 +262,22 @@ function emcp_connector_consume_pair_code( string $code ) { return emcp_connector_secret(); } -/** HTTPS (or loopback) gate for the secret-carrying exchange route. */ +/** + * HTTPS (or loopback) gate for the secret-carrying exchange route. + * + * Loopback is decided from server-side state — the site's configured home_url() + * host, then $_SERVER['SERVER_ADDR'] — never the client-supplied Host header. + */ function emcp_connector_pair_exchange_allowed(): bool { if ( is_ssl() ) { return true; } - $host = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( (string) $_SERVER['HTTP_HOST'] ) : ''; - $host = preg_replace( '/:\d+$/', '', (string) $host ); - return in_array( $host, array( 'localhost', '127.0.0.1', '::1', '[::1]' ), true ); + $home = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); + if ( in_array( $home, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { + return true; + } + $addr = isset( $_SERVER['SERVER_ADDR'] ) ? trim( (string) $_SERVER['SERVER_ADDR'], '[]' ) : ''; + return in_array( $addr, array( '127.0.0.1', '::1' ), true ); } /** POST /pair/issue (HMAC) — issue a single-use code (for scripting). */ @@ -811,6 +873,15 @@ function emcp_connector_restore_archive( string $archive ): array { * @param array $stats In/out restore stats. */ function emcp_connector_restore_db( ZipArchive $zip, array $manifest, string $work, array &$stats ): void { + // Cross-prefix guard: dumps write literal table names, so an archive from a + // different DB prefix would import foreign-prefixed tables here. + if ( ! empty( $manifest['db_prefix'] ) ) { + global $wpdb; + if ( (string) $manifest['db_prefix'] !== (string) $wpdb->prefix ) { + $stats['errors'][] = 'prefix_mismatch'; + return; + } + } $src = $zip->getStream( 'database.sql' ); if ( false === $src ) { // No database.sql in the archive — a files-only (or DB-scoped-out) sync. @@ -878,7 +949,10 @@ add_action( 'admin_post_emcp_connector_admin', function () { if ( defined( 'EMCP_CONNECTOR_SECRET' ) && EMCP_CONNECTOR_SECRET ) { set_transient( 'emcp_connector_admin_notice', array( 'type' => 'error', 'message' => 'The secret is defined by EMCP_CONNECTOR_SECRET in wp-config.php and cannot be changed here.' ), 60 ); } else { - $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification -- check_admin_referer above. + // Store the secret verbatim (wp_unslash only): it is used byte-for-byte + // for HMAC and returned unchanged by pairing, so sanitize_text_field() + // must not rewrite whitespace or special characters. + $secret = isset( $_POST['secret'] ) ? (string) wp_unslash( $_POST['secret'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification -- check_admin_referer above, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verbatim secret. update_option( 'emcp_connector_secret', $secret, false ); set_transient( 'emcp_connector_admin_notice', array( 'type' => 'success', 'message' => 'Connector secret saved.' ), 60 ); } diff --git a/includes/abilities/class-migrate-abilities.php b/includes/abilities/class-migrate-abilities.php index dc1568b..826f8dd 100644 --- a/includes/abilities/class-migrate-abilities.php +++ b/includes/abilities/class-migrate-abilities.php @@ -96,10 +96,20 @@ class EMCP_Tools_Migrate_Abilities { 'secret_key' => array( 'type' => 'string', 'description' => __( 'Shared secret (must match EMCP_CONNECTOR_SECRET on the destination).', 'emcp-tools' ) ), 'scope' => array( 'type' => 'object', - 'description' => __( 'What to sync. Omit for a full site. tables: [] syncs the whole DB; a list syncs only those tables; "none" skips the DB. file_roots: [] syncs all files; a list of uploads/plugins/themes (or a wp-content-relative path) syncs only those roots; "none" skips files.', 'emcp-tools' ), + 'description' => __( 'What to sync. Omit the whole scope for a full site. Each of tables / file_roots is "all", "none", or a list — an empty list maps to "none". tables lists DB tables; file_roots lists uploads/plugins/themes (or a wp-content-relative path).', 'emcp-tools' ), 'properties' => array( - 'tables' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), - 'file_roots' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), + 'tables' => array( + 'oneOf' => array( + array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), + array( 'type' => 'string', 'enum' => array( 'all', 'none' ) ), + ), + ), + 'file_roots' => array( + 'oneOf' => array( + array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), + array( 'type' => 'string', 'enum' => array( 'all', 'none' ) ), + ), + ), ), ), 'confirm' => array( 'type' => 'boolean', 'description' => __( 'Overwrites the selected tables/files on the destination — must be true.', 'emcp-tools' ) ), diff --git a/includes/admin/views/page-migrate.php b/includes/admin/views/page-migrate.php index 7f2a40d..972a35a 100644 --- a/includes/admin/views/page-migrate.php +++ b/includes/admin/views/page-migrate.php @@ -312,6 +312,7 @@ $emcp_roots = class_exists( 'EMCP_Tools_Sync_Engine' ) ? EMCP_Tools_Sync_Engin

- + + + +
- - - + + + + +

diff --git a/includes/migrate/class-migrate-targets.php b/includes/migrate/class-migrate-targets.php index 553892c..943dc92 100644 --- a/includes/migrate/class-migrate-targets.php +++ b/includes/migrate/class-migrate-targets.php @@ -108,6 +108,11 @@ class EMCP_Tools_Migrate_Targets { return new WP_Error( 'duplicate_target', __( 'A paired target for this URL already exists.', 'emcp-tools' ) ); } + $cipher = self::encrypt( $secret ); + if ( '' === $cipher ) { + return new WP_Error( 'encrypt_failed', __( 'Could not encrypt the shared secret — the target was not stored.', 'emcp-tools' ) ); + } + $now = function_exists( 'current_time' ) ? current_time( 'mysql' ) : gmdate( 'Y-m-d H:i:s' ); global $wpdb; $inserted = $wpdb->insert( @@ -116,7 +121,7 @@ class EMCP_Tools_Migrate_Targets { 'label' => $label, 'target_url' => $target_url, 'endpoint' => self::normalize_endpoint( $target_url ), - 'secret_cipher' => self::encrypt( $secret ), + 'secret_cipher' => $cipher, 'site_url' => sanitize_text_field( (string) ( $data['site_url'] ?? '' ) ), 'connector_version' => sanitize_text_field( (string) ( $data['connector_version'] ?? '' ) ), 'confirmed_at' => $now, @@ -316,16 +321,23 @@ class EMCP_Tools_Migrate_Targets { * Prove a secret signs on the destination (signed GET /verify). Shared by * pairing (redeem_pairing_code) and the admin Verify-target action. * + * The signature binds a fresh per-request timestamp + nonce (connector 1.2.0 + * replay protection) — canonical `verify||`, mirrored byte-identically + * by the connector's emcp_connector_canonical(). + * * @param string $endpoint Connector REST base. * @param string $secret Connector secret. * @return bool */ public static function verify_endpoint( string $endpoint, string $secret ): bool { - $response = wp_remote_get( - $endpoint . '/verify', + $ts = time(); + $nonce = bin2hex( random_bytes( 12 ) ); + $canonical = 'verify|' . $ts . '|' . $nonce; + $response = wp_remote_get( + add_query_arg( array( 'ts' => $ts, 'nonce' => $nonce ), $endpoint . '/verify' ), array( 'timeout' => 30, - 'headers' => array( 'X-EMCP-Signature' => hash_hmac( 'sha256', 'verify|', $secret ) ), + 'headers' => array( 'X-EMCP-Signature' => hash_hmac( 'sha256', $canonical, $secret ) ), ) ); if ( is_wp_error( $response ) ) { @@ -389,6 +401,9 @@ class EMCP_Tools_Migrate_Targets { $key = hash( 'sha256', ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : '' ), true ); $iv = openssl_random_pseudo_bytes( 16 ); $cipher = openssl_encrypt( $plain, 'AES-256-CBC', $key, 0, $iv ); + if ( false === $cipher ) { + return ''; // Never persist a bare-IV blob that decrypt() would reject. + } return base64_encode( $iv . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } diff --git a/includes/migrate/class-migration-engine.php b/includes/migrate/class-migration-engine.php index fae2cfd..81cc3c6 100644 --- a/includes/migrate/class-migration-engine.php +++ b/includes/migrate/class-migration-engine.php @@ -263,12 +263,18 @@ class EMCP_Tools_Migration_Engine { * @return array|null Job payload (state done/error/…), null when still pending after the bound. */ public static function poll_job( string $endpoint, string $secret, string $job_id ) { - $job = null; - for ( $i = 0; $i < 30; $i++ ) { + $job = null; + $deadline = microtime( true ) + 60; + do { + $remaining = $deadline - microtime( true ); + if ( $remaining <= 1 ) { + break; + } + $timeout = max( 1, (int) min( 30, $remaining ) ); $response = wp_remote_get( $endpoint . '/job/' . rawurlencode( $job_id ), array( - 'timeout' => 30, + 'timeout' => $timeout, 'headers' => array( 'X-EMCP-Signature' => hash_hmac( 'sha256', 'job|' . $job_id, $secret ) ), ) ); @@ -281,8 +287,11 @@ class EMCP_Tools_Migration_Engine { } } } - sleep( 2 ); // phpcs:ignore WordPress.PHP -- poll cadence for a remote batch job. - } + // Pace requests while the deadline allows. + if ( $deadline - microtime( true ) > 2 ) { + sleep( 2 ); // phpcs:ignore WordPress.PHP -- poll cadence for a remote batch job. + } + } while ( microtime( true ) < $deadline ); return $job; } @@ -320,8 +329,10 @@ class EMCP_Tools_Migration_Engine { $archive = basename( $path ); - // Without polling, report the handed-out job id and let the caller poll. - if ( empty( $opts['poll'] ) ) { + // Poll by default so push callers learn the true destination state; pass + // poll => false to hand back the job id without waiting. + $poll = array_key_exists( 'poll', $opts ) ? (bool) $opts['poll'] : true; + if ( ! $poll ) { return array( 'success' => true, 'job_id' => $job_id, @@ -332,7 +343,7 @@ class EMCP_Tools_Migration_Engine { } // Poll the job until it settles (the connector restores inline, so this - // normally returns on the first poll; bounded at ~60 s). + // normally returns on the first poll); poll_job() enforces a ~60 s cap. $job = self::poll_job( $endpoint, $secret, $job_id ); $state = ( $job && isset( $job['state'] ) ) ? $job['state'] : 'unknown'; diff --git a/includes/migrate/class-packager.php b/includes/migrate/class-packager.php index c5d0144..72cd073 100644 --- a/includes/migrate/class-packager.php +++ b/includes/migrate/class-packager.php @@ -212,6 +212,9 @@ class EMCP_Tools_Packager { 'partial' => ! empty( $db_stats['partial'] ), ); $manifest['database_sha256'] = hash_file( 'sha256', $tmp_sql ); + // Source DB prefix so a restore can refuse cleanly when it differs + // from the destination (imports write literal table names). + $manifest['db_prefix'] = self::site_db_prefix(); } $manifest['files'] = count( $file_entries ); $manifest['file_roots'] = $file_entries; @@ -362,11 +365,21 @@ class EMCP_Tools_Packager { if ( ! $file->isFile() ) { continue; } - $zip_name = self::zip_relative_name( wp_normalize_path( $file->getPathname() ), $content_dir ); + $local = wp_normalize_path( $file->getPathname() ); + // Resolve symlinks and refuse anything that escapes wp-content — a + // scoped root could otherwise exfiltrate a linked target outside it. + $real = realpath( $local ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $real ) { + continue; + } + if ( 0 !== strpos( wp_normalize_path( $real ), $content_dir . '/' ) ) { + continue; + } + $zip_name = self::zip_relative_name( $local, $content_dir ); if ( '' === $zip_name ) { continue; // Outside wp-content — never bundled. } - if ( ! $zip->addFile( $file->getPathname(), $zip_name ) ) { + if ( ! $zip->addFile( $local, $zip_name ) ) { return false; // Propagate failure so create_archive cannot report a complete bundle. } $count++; @@ -472,4 +485,14 @@ class EMCP_Tools_Packager { } return ''; } + + /** + * The site's DB table prefix (recorded in the manifest for cross-site guards). + * + * @return string + */ + private static function site_db_prefix(): string { + global $wpdb; + return ( isset( $wpdb ) && isset( $wpdb->prefix ) ) ? (string) $wpdb->prefix : ''; + } } diff --git a/includes/migrate/class-restore-engine.php b/includes/migrate/class-restore-engine.php index 3796cab..3429a96 100644 --- a/includes/migrate/class-restore-engine.php +++ b/includes/migrate/class-restore-engine.php @@ -67,6 +67,15 @@ class EMCP_Tools_Restore_Engine { if ( isset( $manifest['format_version'] ) && (int) $manifest['format_version'] > EMCP_Tools_Packager::FORMAT_VERSION ) { return new WP_Error( 'newer_format', __( 'This archive uses a newer format than this plugin can restore.', 'emcp-tools' ) ); } + // Cross-prefix guard: dumps write literal table names, so restoring an + // archive built on a different DB prefix onto this site would import + // foreign-prefixed tables and break search-replace. Refuse cleanly. + if ( ! empty( $manifest['db_prefix'] ) ) { + global $wpdb; + if ( isset( $wpdb ) && (string) $manifest['db_prefix'] !== (string) $wpdb->prefix ) { + return new WP_Error( 'prefix_mismatch', __( 'This archive was created on a site with a different database table prefix and cannot be restored here.', 'emcp-tools' ) ); + } + } if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); // phpcs:ignore WordPress.PHP -- restore is an admin-authorized batch op. diff --git a/includes/migrate/class-sync-engine.php b/includes/migrate/class-sync-engine.php index 3ce3558..ada999f 100644 --- a/includes/migrate/class-sync-engine.php +++ b/includes/migrate/class-sync-engine.php @@ -354,10 +354,10 @@ class EMCP_Tools_Sync_Engine { return 'none'; } $map = array(); - foreach ( $files as $token ) { + foreach ( $files as $key => $token ) { $abs = self::file_root_path( (string) $token ); if ( '' !== $abs ) { - $map[ is_int( $token ) ? self::root_label( $abs ) : (string) $token ] = $abs; + $map[ is_int( $key ) ? self::root_label( $abs ) : (string) $token ] = $abs; } } return $map;