'POST', 'callback' => [ $this, 'handle' ], 'permission_callback' => [ $this, 'authorize' ], 'args' => [ 'remote_url' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => [ Sanitizer::class, 'url' ], ], 'remote_token' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'label' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => [ Sanitizer::class, 'label' ], 'default' => '', ], 'capabilities' => [ 'required' => false, 'type' => 'object', 'default' => [], ], 'plugin_version' => [ 'required' => false, 'type' => 'string', 'default' => '', ], 'db_prefix' => [ 'required' => false, 'type' => 'string', 'default' => '', 'sanitize_callback' => static fn( string $value ): string => (string) preg_replace( '/[^a-zA-Z0-9_]/', '', $value ), ], ], ] ); } /** Pairing secret check; fails closed when this site has no secret. */ public static function secret_matches( string $site_secret, string $given ): bool { return '' !== $site_secret && hash_equals( $site_secret, $given ); } public static function caller_ip( mixed $raw ): string { $ip = is_string( $raw ) ? filter_var( $raw, FILTER_VALIDATE_IP ) : false; return false === $ip ? 'unknown' : $ip; } public function authorize( WP_REST_Request $request ): bool|WP_Error { $caller_ip = self::caller_ip( ServerVars::remote_addr() ); // At most 5 failed attempts per IP per 5 minutes. A caller with the right secret // never spends the budget. $rate_key = 'shiftora_hs_rl_' . md5( $caller_ip ); $rate_count = (int) get_transient( $rate_key ); if ( $rate_count >= 5 ) { ServerLog::write( sprintf( '[Shiftora] Handshake rate-limit exceeded. IP: %s', $caller_ip ) ); return new WP_Error( 'shiftora_rate_limited', __( 'Too many handshake attempts. Please wait 5 minutes and try again.', 'shiftora' ), [ 'status' => 429 ] ); } // Must match this site's secret key, so only someone with the Connection Info can pair. $site_secret = (string) get_option( 'shiftora_secret_key', '' ); $given = (string) $request->get_param( 'remote_token' ); if ( ! self::secret_matches( $site_secret, $given ) ) { set_transient( $rate_key, $rate_count + 1, 5 * MINUTE_IN_SECONDS ); ServerLog::write( sprintf( '[Shiftora] Handshake auth failure. IP: %s', $caller_ip ) ); return self::invalid_secret(); } // Everything that can refuse a valid secret runs before the secret is spent. $remote_url = (string) $request->get_param( 'remote_url' ); if ( '' === $remote_url ) { return new WP_Error( 'shiftora_invalid_url', __( 'A valid remote URL is required.', 'shiftora' ), [ 'status' => 422 ] ); } // Refuse a connection to this very site, comparing normalized URLs. if ( Sanitizer::normalize_url_for_comparison( $remote_url ) === Sanitizer::normalize_url_for_comparison( get_site_url() ) ) { return new WP_Error( 'shiftora_self_connect', __( 'Cannot connect a site to itself.', 'shiftora' ), [ 'status' => 422 ] ); } // The pairing secret alone must not replace a live peer's tokens or bring a revoked // one back; the admin removes the old connection first. if ( $this->connections()->find_by_url( $remote_url ) ) { return new WP_Error( 'shiftora_connection_exists', __( 'The target site already has a connection for the site you are connecting from. Delete that connection on the target site first, then connect again.', 'shiftora' ), [ 'status' => 409 ] ); } // One winner per secret: parallel handshakes carrying the same key cannot both pass, // so at most one connection is created and the key is spent in the same statement. if ( ! $this->consume_secret( $site_secret ) ) { return self::invalid_secret(); } return true; } private static function invalid_secret(): WP_Error { return new WP_Error( 'shiftora_invalid_token', __( 'Invalid secret key. The key changes after every successful pairing, so copy the current one from Connection Info on the target site and try again.', 'shiftora' ), [ 'status' => 403 ] ); } /** Swaps the secret for a new one only if it still holds the value the caller proved. */ private function consume_secret( string $current ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Compare-and-swap that update_option() cannot express. $changed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET option_value = %s WHERE option_name = %s AND option_value = %s', $wpdb->options, TokenGenerator::generate_secret(), 'shiftora_secret_key', $current ) ); // The write bypassed the option API, so its caches still hold the spent key. wp_cache_delete( 'shiftora_secret_key', 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); return 1 === (int) $changed; } public function handle( WP_REST_Request $request ): WP_REST_Response|WP_Error { $remote_url = $request->get_param( 'remote_url' ); $label = $request->get_param( 'label' ); $remote_plugin_version = sanitize_text_field( (string) $request->get_param( 'plugin_version' ) ); $remote_db_prefix = (string) $request->get_param( 'db_prefix' ); $local_token = TokenGenerator::generate(); if ( empty( $label ) ) { $label = wp_parse_url( $remote_url, PHP_URL_HOST ) ?: $remote_url; } try { // Both token fields get the same value by design: this site needs sha256() of it to // find the caller and the plaintext to recompute its signatures. The caller signs with // the same value, so anything else here fails every request with a 401. $connection_id = $this->connections()->create( $label, $remote_url, $local_token, $local_token, '' !== $remote_db_prefix ? $remote_db_prefix : 'wp_', 1, $remote_url, $remote_plugin_version ); $this->connections()->activate( $connection_id ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_db_error', __( 'Failed to create connection. Please try again.', 'shiftora' ), [ 'status' => 500 ] ); } global $wpdb; ServerLog::write( sprintf( '[Shiftora] Handshake accepted. remote_url: %s, connection_id: %d', $remote_url, $connection_id ) ); // What the caller announced is pinned here; the answer pins this site's for the caller. $caller_capabilities = $request->get_param( 'capabilities' ); if ( is_array( $caller_capabilities ) ) { PairingCapabilities::pin( (string) $remote_url, $caller_capabilities ); } HttpClient::remember_ajax_url( (string) $remote_url, $request->get_param( 'ajax_url' ) ); $capabilities = PairingCapabilities::advertised(); // Keyed with the pairing secret the caller just proved, which this answer never repeats. $signature = PairingCapabilities::sign( (string) $request->get_param( 'remote_token' ), $local_token, $capabilities ); return new WP_REST_Response( [ 'success' => true, 'local_token' => $local_token, 'capabilities' => $capabilities, 'capabilities_signature' => $signature, 'site_url' => esc_url_raw( get_site_url() ), 'db_prefix' => $wpdb->prefix, 'ajax_url' => admin_url( 'admin-ajax.php' ), 'version' => SHIFTORA_VERSION, 'transfer_protocol' => TransferProtocol::CURRENT, 'allow_pull' => (bool) get_option( 'shiftora_allow_pull', true ), ], 201 ); } private function connections(): ConnectionManager { return $this->connections ?? new ConnectionManager(); } }