mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-11 21:09:58 +00:00
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:
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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' ) ),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user