fix(migrate): address CodeRabbit review — replay protection, prefix guard, poll deadline

Security:
- pair/issue + verify signatures now bind a per-request ts + single-use nonce
  (canonical 'pair|issue|<ts>|<nonce>' / 'verify|<ts>|<nonce>'); the connector
  rejects stale timestamps (+-300s) and replayed nonces (capped store), and the
  plugin's verify_endpoint() signs the same canonical, so a captured signature
  cannot be replayed to mint/verify pairing codes.
- Loopback decision for the secret-carrying exchange is now server-side
  (home_url() host + SERVER_ADDR), never the client Host header.
- The connector admin secret is stored verbatim (wp_unslash only) — no
  sanitize_text_field rewriting bytes used for HMAC.

Correctness:
- push_archive() defaults poll=true again (migrate-site/admin-push report the
  true destination job state) and poll_job() enforces a ~60s wall-clock
  deadline with per-request capped timeouts.
- Cross-prefix restores fail closed: manifests record db_prefix; source restore
  and the connector refuse with prefix_mismatch instead of importing
  foreign-prefixed tables.
- sync-to-live scope schema accepts 'all'/'none' or lists, and the description
  no longer claims empty arrays mean full sync; scope file-root manifest keys
  use the intended root_label for numeric keys.
- Targets store computes+validates the cipher before insert (no bare-IV rows);
  packager add_root_files() realpath-checks containment so a symlink cannot
  exfiltrate outside wp-content.

Admin UI:
- Push form wraps build/existing archive groups in the emcp-push-build /
  emcp-push-existing classes migrate.js toggles (the toggle was previously dead)
  and adds a "-- none --" destination option so raw URL+secret pushes work.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John Smith
2026-09-03 12:32:13 -04:00
co-authored by Claude
parent 130aa5a334
commit 431556842a
8 changed files with 186 additions and 39 deletions
+85 -11
View File
@@ -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 );
}
+13 -3
View File
@@ -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' ) ),
+14 -9
View File
@@ -312,6 +312,7 @@ $emcp_roots = class_exists( 'EMCP_Tools_Sync_Engine' ) ? EMCP_Tools_Sync_Engin
<p>
<label for="emcp_push_target"><?php esc_html_e( 'Destination:', 'emcp-tools' ); ?></label>
<select name="target_id" id="emcp_push_target">
<option value=""><?php esc_html_e( '— none (use the advanced fields below) —', 'emcp-tools' ); ?></option>
<?php foreach ( $emcp_targets as $emcp_t ) : ?>
<option value="<?php echo (int) $emcp_t['id']; ?>"><?php echo esc_html( $emcp_t['label'] . ' — ' . $emcp_t['target_url'] ); ?></option>
<?php endforeach; ?>
@@ -328,17 +329,21 @@ $emcp_roots = class_exists( 'EMCP_Tools_Sync_Engine' ) ? EMCP_Tools_Sync_Engin
</p>
<p>
<label for="emcp_push_archive"><?php esc_html_e( 'Archive source:', 'emcp-tools' ); ?></label><br>
<label><input type="radio" name="archive_source" value="build" checked> <?php esc_html_e( 'Build a fresh full archive', 'emcp-tools' ); ?></label>
<label style="margin-left:10px"><input type="checkbox" name="include_files" value="1"> <?php esc_html_e( '…with site files', 'emcp-tools' ); ?></label>
<span class="emcp-push-build">
<label><input type="radio" name="archive_source" value="build" checked> <?php esc_html_e( 'Build a fresh full archive', 'emcp-tools' ); ?></label>
<label style="margin-left:10px"><input type="checkbox" name="include_files" value="1"> <?php esc_html_e( '…with site files', 'emcp-tools' ); ?></label>
</span>
<br>
<?php if ( ! empty( $emcp_backups ) ) : ?>
<label><input type="radio" name="archive_source" value="existing"> <?php esc_html_e( 'Use an existing archive:', 'emcp-tools' ); ?></label>
<label for="emcp_push_archive_select" class="screen-reader-text"><?php esc_html_e( 'Choose an archive', 'emcp-tools' ); ?></label>
<select name="archive" id="emcp_push_archive_select">
<?php foreach ( $emcp_backups as $emcp_b ) : ?>
<option value="<?php echo esc_attr( $emcp_b['filename'] ); ?>"><?php echo esc_html( $emcp_b['filename'] . ' (' . $emcp_b['size'] . ')' ); ?></option>
<?php endforeach; ?>
</select>
<span class="emcp-push-existing">
<label><input type="radio" name="archive_source" value="existing"> <?php esc_html_e( 'Use an existing archive:', 'emcp-tools' ); ?></label>
<label for="emcp_push_archive_select" class="screen-reader-text"><?php esc_html_e( 'Choose an archive', 'emcp-tools' ); ?></label>
<select name="archive" id="emcp_push_archive_select">
<?php foreach ( $emcp_backups as $emcp_b ) : ?>
<option value="<?php echo esc_attr( $emcp_b['filename'] ); ?>"><?php echo esc_html( $emcp_b['filename'] . ' (' . $emcp_b['size'] . ')' ); ?></option>
<?php endforeach; ?>
</select>
</span>
<?php endif; ?>
</p>
<p>
+19 -4
View File
@@ -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|<ts>|<nonce>`, 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
}
+19 -8
View File
@@ -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';
+25 -2
View File
@@ -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 : '';
}
}
@@ -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.
+2 -2
View File
@@ -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;