Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions admin/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public function handle_form_submission() {

// Force regeneration of secure filenames
delete_option('custom_migrator_filenames');

// Drop any temp file name left over from a previous export
delete_option('custom_migrator_db_temp_file');

// Update export status
$this->filesystem->write_status( 'starting' );
Expand Down Expand Up @@ -377,6 +380,13 @@ private function calculate_directory_size($directory, $exclusion_paths) {
* @return void
*/
public function delete_plugin() {
// Verify the request actually came from our admin screen (the client already sends
// this nonce); without it this endpoint is CSRF-able into deleting the plugin.
if ( ! check_ajax_referer( 'custom_migrator_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
return;
}

// Verify user capabilities
if ( ! current_user_can( 'activate_plugins' ) ) {
wp_send_json_error( array( 'message' => 'You do not have sufficient permissions to delete plugins.' ) );
Expand Down Expand Up @@ -428,6 +438,7 @@ private function run_plugin_cleanup() {

// Remove plugin options (same as in uninstall.php)
delete_option( 'custom_migrator_filenames' );
delete_option( 'custom_migrator_db_temp_file' );
delete_option( 'custom_migrator_access_token' );
delete_option( 'custom_migrator_auth' );
delete_option( 'custom_migrator_export_subdir' );
Expand Down
1 change: 1 addition & 0 deletions admin/js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function processFallbackStep(step, params) {
type: 'POST',
data: {
action: 'cm_fallback_export',
nonce: cm_ajax.nonce,
step: step,
params: params
},
Expand Down
77 changes: 77 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
Changelog
=========

All notable changes to the Hostinger Migrator plugin are documented in this file.
Versions before 1.1.0 predate this changelog and are not listed.


1.1.0 - 2026-09-18
------------------

Security release. Upgrading is strongly recommended for every install.

Security

* Removed the wp_ajax_nopriv registrations for "cm_fallback_export" and
"cm_fallback_status". Both handlers ran a full database and file export and
were reachable by unauthenticated visitors. They now require the
manage_options capability and a valid nonce, checked before any filesystem
work is performed.
* The database export no longer accepts a "temp_file_path" parameter from the
request. On resume, the temp file name is recovered from server-side state
and the directory is derived from the generated SQL file path, so the SQL
dump can no longer be steered to an arbitrary web-server-writable location.
A final check rejects any resolved path outside the export directory or with
an unexpected file name.
* Removed the "background_mode" authentication bypass in "cm_run_export_now".
The flag was a plain request parameter, so any logged-in user could use it to
skip the capability and nonce checks and start a full export. Background
execution continues through the cm_run_export cron hook.
* Gave "cm_process_export_step" a dedicated AJAX entry point with capability
and nonce checks. The underlying method no longer merges $_GET/$_POST into
its parameters and is now internal only.
* Added the missing nonce check to plugin deletion ("cm_delete_plugin"), which
was CSRF-able into deleting the plugin and all export data.
* Added a capability check to "cm_get_export_status_display" and
"cm_get_s3_status_display", which previously exposed migration status to any
logged-in user.
* Sanitized the fallback export session id, which was taken from the request
unfiltered and written to the lock file and log.

Changed

* The export directory .htaccess is now default-deny with an allow list limited
to the randomly named export artifacts. Status, step, lock, content-list and
temporary SQL files are no longer served. The rules emit both Apache 2.2 and
2.4 authorization syntax so they neither silently no-op nor fail depending on
which authz module is loaded, and PHP execution is disabled for the
directory.
* The export directory guard files (.htaccess, index.php) are now (re)written
whenever they are missing or out of date, instead of only when the directory
is first created. Existing installs are healed on the next admin page load.
* All code paths that create the export directory now go through
Custom_Migrator_Filesystem::create_export_dir(). The fallback exporter and
the database exporter previously created it with a bare wp_mkdir_p(), which
produced a directory with no access protection at all.

Fixed

* The plugin header version (1.0) and CUSTOM_MIGRATOR_VERSION constant (1.0.0)
no longer disagree.

Notes for hosting environments

* The export directory protection relies on .htaccess, which Apache and
LiteSpeed honour but nginx ignores. On nginx the export artifacts are
protected only by the random component of their file names. Serving
downloads through an authenticated endpoint is tracked as follow-up work.

Upgrade notes

* Any external automation that called "cm_fallback_export",
"cm_fallback_status", "cm_run_export_now" or "cm_process_export_step" without
an authenticated session will stop working. These endpoints now require an
administrator session and a "custom_migrator_nonce" nonce.
* Adds the "custom_migrator_db_temp_file" option, which holds the active
database export temp file name. It is cleared when an export finishes and
removed on uninstall.
4 changes: 2 additions & 2 deletions custom-migrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: Hostinger Migrator
* Description: Exports wp-content as a .hstgr file and the database as a separate .sql.gz file with metadata in .json
* Version: 1.0
* Version: 1.1.0
* Author: Your Name
* License: GPL-2.0+
* Text Domain: custom-migrator
Expand All @@ -16,7 +16,7 @@
}

// Define plugin constants.
define( 'CUSTOM_MIGRATOR_VERSION', '1.0.0' );
define( 'CUSTOM_MIGRATOR_VERSION', '1.1.0' );
define( 'CUSTOM_MIGRATOR_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'CUSTOM_MIGRATOR_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'CUSTOM_MIGRATOR_ADMIN_URL', admin_url( 'admin.php?page=custom-migrator' ) );
Expand Down
92 changes: 63 additions & 29 deletions includes/class-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ private function define_admin_hooks() {
add_action( 'admin_menu', array( $admin, 'add_admin_menu' ) );
add_action( 'admin_enqueue_scripts', array( $admin, 'enqueue_scripts' ) );
add_action( 'admin_init', array( $admin, 'handle_form_submission' ) );

// Self-heal the export directory protection on existing installs (cheap no-op
// once the .htaccess/index.php guards are already in place).
add_action( 'admin_init', array( $this, 'ensure_export_dir_protected' ) );

// Add settings link to the plugins page
add_filter( 'plugin_action_links_' . plugin_basename( CUSTOM_MIGRATOR_PLUGIN_DIR . 'custom-migrator.php' ),
Expand All @@ -97,6 +101,19 @@ private function define_admin_hooks() {
add_filter('cron_schedules', array($this, 'add_custom_cron_schedules'));
}

/**
* Make sure the export directory still carries its access protection.
*
* Older versions only wrote .htaccess/index.php at directory creation time, and the
* fallback exporter used to create the directory without them, so existing installs
* can have an unprotected export directory.
*
* @return void
*/
public function ensure_export_dir_protected() {
$this->filesystem->protect_export_dir();
}

/**
* Register all of the hooks related to AJAX functionality.
*
Expand All @@ -106,26 +123,25 @@ private function define_ajax_hooks() {
// AJAX handlers
add_action( 'wp_ajax_cm_start_export', array( $this, 'handle_start_export' ) );
add_action( 'wp_ajax_cm_check_status', array( $this, 'handle_check_status' ) );
add_action( 'wp_ajax_cm_process_export_step', array( $this, 'process_export_step' ) );
add_action( 'wp_ajax_cm_process_export_step', array( $this, 'handle_process_export_step' ) );
add_action( 'wp_ajax_cm_force_continue', array( $this, 'handle_force_continue' ) );
add_action( 'wp_ajax_cm_run_export_now', array( $this, 'handle_run_export_now' ) );
add_action( 'wp_ajax_cm_upload_to_s3', array( $this, 'handle_upload_to_s3' ) );
add_action( 'wp_ajax_cm_check_s3_status', array( $this, 'handle_check_s3_status' ) );
add_action( 'wp_ajax_cm_debug_status', array( $this, 'handle_debug_status' ) );

// Status display handlers (no privilege required for UI display)
// Status display handlers (read-only, admin capability required)
add_action( 'wp_ajax_cm_get_export_status_display', array( $this, 'handle_get_export_status_display' ) );
add_action( 'wp_ajax_cm_get_s3_status_display', array( $this, 'handle_get_s3_status_display' ) );

// Plugin management handlers
add_action( 'wp_ajax_cm_delete_plugin', array( $this, 'handle_delete_plugin' ) );

// FALLBACK AJAX EXPORT SYSTEM - Following All-in-One WP Migration approach
// Register both privileged and non-privileged actions for maximum compatibility
// Privileged only: these handlers run a full site export, so they must never be
// reachable by unauthenticated visitors (no wp_ajax_nopriv registration).
add_action( 'wp_ajax_cm_fallback_export', array( $this->fallback_exporter, 'handle_fallback_export' ) );
add_action( 'wp_ajax_nopriv_cm_fallback_export', array( $this->fallback_exporter, 'handle_fallback_export' ) );
add_action( 'wp_ajax_cm_fallback_status', array( $this->fallback_exporter, 'handle_fallback_status' ) );
add_action( 'wp_ajax_nopriv_cm_fallback_status', array( $this->fallback_exporter, 'handle_fallback_status' ) );

add_action( 'cm_run_export', array( $this, 'run_export' ) );
}
Expand Down Expand Up @@ -391,17 +407,16 @@ public function handle_upload_to_s3() {
* @return void
*/
public function handle_run_export_now() {
// Check if this is a background request
$is_background = isset($_REQUEST['background_mode']) && $_REQUEST['background_mode'] === '1';

if (!$is_background) {
// For foreground requests, use standard WordPress security
if ( ! current_user_can( 'manage_options' ) || ! check_ajax_referer( 'custom_migrator_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}
// Security check - unconditional. 'background_mode' used to skip this, but it is
// just a request parameter that any caller can set. Cookie-less background triggers
// cannot authenticate here in any case; they run through the cm_run_export cron hook.
if ( ! current_user_can( 'manage_options' ) || ! check_ajax_referer( 'custom_migrator_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}
// Background requests are triggered by authenticated requests, so they don't need additional auth


// Only used to decide how aggressively to detach this (long-running) request.
$is_background = isset($_REQUEST['background_mode']) && $_REQUEST['background_mode'] === '1';

$this->filesystem->log('Processing export request (background: ' . ($is_background ? 'yes' : 'no') . ')');

// Set proper execution environment for background processing
Expand Down Expand Up @@ -484,6 +499,9 @@ public function handle_start_export() {
// Important: Delete old filenames to force regeneration with new secure names
delete_option('custom_migrator_filenames');

// Drop any temp file name left over from a previous export
delete_option(Custom_Migrator_Database_Exporter::TEMP_FILE_OPTION);

// Update export status and immediately start background processing
$this->filesystem->write_status( 'starting' );
$this->filesystem->log('Export started, initiating immediate background processing');
Expand Down Expand Up @@ -655,28 +673,36 @@ private function test_background_http() {
return $response_code === 200;
}

/**
* AJAX entry point for step-by-step export processing.
*
* @return array Updated parameters.
*/
public function handle_process_export_step() {
// Security check
if ( ! current_user_can( 'manage_options' ) || ! check_ajax_referer( 'custom_migrator_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}

$params = stripslashes_deep( array_merge( $_GET, $_POST ) );

return $this->process_export_step( $params );
}

/**
* Process export step by step (simple automation).
*
* Internal only: every caller supplies its own parameters. Request-driven callers must
* go through handle_process_export_step(), which performs the capability/nonce check.
*
* @param array $params Export parameters.
* @return array Updated parameters.
*/
public function process_export_step($params = array()) {
// Get params from request if not provided
if (empty($params)) {
$params = stripslashes_deep(array_merge($_GET, $_POST));
}

// Detect execution context
// Detect execution context (logging and timeout handling only)
$is_cron = defined('DOING_CRON') && DOING_CRON;
$is_ajax = defined('DOING_AJAX') && DOING_AJAX;
$is_background = $is_cron || !$is_ajax;

// Simple security check for non-background requests
if (!$is_background && !current_user_can('manage_options')) {
$this->filesystem->log('Security check failed - user lacks permissions');
return $params;
}

$current_step = isset($params['step']) ? $params['step'] : 'unknown';
$this->filesystem->log('Processing export step: ' . $current_step . ' (background: ' . ($is_background ? 'yes' : 'no') . ')');
Expand Down Expand Up @@ -1759,11 +1785,15 @@ public function add_custom_cron_schedules($schedules) {

/**
* Handle AJAX request to get export status for UI display.
* No security check needed as this just reads status text file content.
*
* @return void
*/
public function handle_get_export_status_display() {
// Security check - the export status reveals migration activity on the site.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}

// Enhanced cache-busting headers
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
Expand Down Expand Up @@ -1794,11 +1824,15 @@ public function handle_get_export_status_display() {

/**
* Handle AJAX request to get S3 upload status for UI display.
* No security check needed as this just reads status text file content.
*
* @return void
*/
public function handle_get_s3_status_display() {
// Security check - the export status reveals migration activity on the site.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}

// Enhanced cache-busting headers
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
Expand Down
Loading