diff --git a/admin/class-admin.php b/admin/class-admin.php
index f92b000..e0b090d 100644
--- a/admin/class-admin.php
+++ b/admin/class-admin.php
@@ -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' );
@@ -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.' ) );
@@ -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' );
diff --git a/admin/js/script.js b/admin/js/script.js
index 466e304..b4d723c 100644
--- a/admin/js/script.js
+++ b/admin/js/script.js
@@ -92,6 +92,7 @@ function processFallbackStep(step, params) {
type: 'POST',
data: {
action: 'cm_fallback_export',
+ nonce: cm_ajax.nonce,
step: step,
params: params
},
diff --git a/changelog.txt b/changelog.txt
new file mode 100644
index 0000000..4bdac2a
--- /dev/null
+++ b/changelog.txt
@@ -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.
diff --git a/custom-migrator.php b/custom-migrator.php
index b323dd6..f8b9eab 100644
--- a/custom-migrator.php
+++ b/custom-migrator.php
@@ -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
@@ -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' ) );
diff --git a/includes/class-core.php b/includes/class-core.php
index fb11ebe..990a7f4 100644
--- a/includes/class-core.php
+++ b/includes/class-core.php
@@ -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' ),
@@ -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.
*
@@ -106,14 +123,14 @@ 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' ) );
@@ -121,11 +138,10 @@ private function define_ajax_hooks() {
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' ) );
}
@@ -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
@@ -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');
@@ -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') . ')');
@@ -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');
@@ -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');
diff --git a/includes/class-database-exporter.php b/includes/class-database-exporter.php
index 41cc03a..cb6920f 100644
--- a/includes/class-database-exporter.php
+++ b/includes/class-database-exporter.php
@@ -50,6 +50,14 @@ class Custom_Migrator_Database_Exporter {
*/
private $temp_file_path;
+ /**
+ * Option key used to persist the active temp file name across resume requests.
+ *
+ * The temp file name is NEVER accepted from the HTTP request: doing so would let a
+ * caller steer the SQL dump to an arbitrary web-server-writable path.
+ */
+ const TEMP_FILE_OPTION = 'custom_migrator_db_temp_file';
+
/**
* Initialize the class.
*
@@ -178,22 +186,37 @@ private function setup_output_file($sql_file) {
$is_resume = ($this->state['tables_processed'] > 0);
$mode = $is_resume ? 'a' : 'w';
- // CRITICAL FIX: For resume operations, reuse the existing temp file path
- // This prevents creating multiple temp files and losing data
- if ($is_resume && !empty($this->state['temp_file_path'])) {
- $this->temp_file_path = $this->state['temp_file_path'];
- $this->filesystem->log("Resuming with existing temp file: " . basename($this->temp_file_path));
- } else {
- // For fresh start, get new temp file path (deterministic naming)
+ // SECURITY: the temp file name is never taken from the request. On resume we recover
+ // the name the previous batch stored server-side, and the directory is always derived
+ // from the (server-generated) SQL file path.
+ $temp_dir = dirname($sql_file);
+ $this->temp_file_path = null;
+
+ if ($is_resume) {
+ $stored_name = get_option(self::TEMP_FILE_OPTION);
+ if (is_string($stored_name) && $this->is_valid_temp_file_name($stored_name)) {
+ $this->temp_file_path = $temp_dir . '/' . $stored_name;
+ $this->filesystem->log("Resuming with existing temp file: " . $stored_name);
+ } else {
+ $this->filesystem->log("WARNING: No valid stored temp file name found for resume, generating a new one");
+ }
+ }
+
+ if (empty($this->temp_file_path)) {
+ // Fresh start (or unrecoverable resume): derive a new temp file path and remember it.
$this->temp_file_path = $this->get_temp_file_path($sql_file);
- $this->state['temp_file_path'] = $this->temp_file_path;
+ update_option(self::TEMP_FILE_OPTION, basename($this->temp_file_path));
}
-
+
+ $this->state['temp_file_path'] = $this->temp_file_path;
+
// ENHANCED: Validate directory and permissions before attempting file creation
- $temp_dir = dirname($this->temp_file_path);
if (!is_dir($temp_dir)) {
$this->filesystem->log("Creating export directory: $temp_dir");
- if (!wp_mkdir_p($temp_dir)) {
+ // Use the helper so the directory is created with its access protection in place.
+ $this->filesystem->create_export_dir();
+
+ if (!is_dir($temp_dir)) {
throw new Exception('Cannot create export directory: ' . $temp_dir);
}
}
@@ -210,6 +233,9 @@ private function setup_output_file($sql_file) {
// ENHANCED: Log temp file being used for better debugging
$this->filesystem->log("Using temp file: " . basename($this->temp_file_path));
+ // SECURITY: final assertion that we only ever write inside the export directory.
+ $this->assert_safe_temp_file_path($temp_dir);
+
$handle = fopen($this->temp_file_path, $mode);
if (!$handle) {
$error = error_get_last();
@@ -752,6 +778,9 @@ private function finalize_export($output_handle, $sql_file) {
$this->filesystem->log("- Temp file size: " . $this->format_bytes($temp_size));
$this->handle_compression($this->temp_file_path, $sql_file);
+
+ // The temp file is consumed; forget it so the next export never resumes onto it.
+ delete_option(self::TEMP_FILE_OPTION);
} else {
$this->filesystem->log("ERROR: Temp file does not exist: " . ($this->temp_file_path ? $this->temp_file_path : 'undefined'));
throw new Exception("Temp file does not exist: " . ($this->temp_file_path ? basename($this->temp_file_path) : 'undefined'));
@@ -907,6 +936,35 @@ private function compress_file($source, $destination) {
return $success;
}
+ /**
+ * Check that a temp file name matches the names this class generates.
+ *
+ * @param string $name Base file name (no directory component).
+ * @return bool True if the name is one we could have produced.
+ */
+ private function is_valid_temp_file_name($name) {
+ return (bool) preg_match('/^db_export_temp_[A-Za-z0-9_.-]+\.sql$/', $name);
+ }
+
+ /**
+ * Refuse to write the SQL dump anywhere but the export directory.
+ *
+ * @param string $temp_dir Directory the temp file lives in.
+ * @throws Exception If the resolved path escapes the export directory.
+ */
+ private function assert_safe_temp_file_path($temp_dir) {
+ $export_dir = realpath($this->filesystem->get_export_dir());
+ $real_temp_dir = realpath($temp_dir);
+
+ if (!$export_dir || !$real_temp_dir || $real_temp_dir !== $export_dir) {
+ throw new Exception('Refusing to write SQL dump outside the export directory: ' . $temp_dir);
+ }
+
+ if (!$this->is_valid_temp_file_name(basename($this->temp_file_path))) {
+ throw new Exception('Refusing to write SQL dump to an unexpected file name: ' . basename($this->temp_file_path));
+ }
+ }
+
/**
* Get temporary file path for processing.
* PRODUCTION-SAFE: Only adds uniqueness when conflicts are detected.
diff --git a/includes/class-fallback-exporter.php b/includes/class-fallback-exporter.php
index d391e21..97b3422 100644
--- a/includes/class-fallback-exporter.php
+++ b/includes/class-fallback-exporter.php
@@ -43,6 +43,11 @@ public function __construct() {
* Handle fallback export AJAX requests
*/
public function handle_fallback_export() {
+ // Security check - must run before any filesystem work is performed.
+ if ( ! current_user_can( 'manage_options' ) || ! check_ajax_referer( 'custom_migrator_nonce', 'nonce', false ) ) {
+ wp_send_json_error( array( 'message' => 'Security check failed' ) );
+ }
+
try {
// Get parameters from AJAX request first
$step = isset($_POST['step']) ? sanitize_text_field($_POST['step']) : 'init';
@@ -52,7 +57,14 @@ public function handle_fallback_export() {
$lock_file = $this->filesystem->get_export_dir() . '/fallback_export.lock';
// Generate or get session ID for this export
- $session_id = isset($params['session_id']) ? $params['session_id'] : uniqid('fallback_', true);
+ $session_id = (isset($params['session_id']) && is_scalar($params['session_id']))
+ ? sanitize_key((string) $params['session_id'])
+ : '';
+ if ($session_id === '') {
+ // No more_entropy: the generated id must survive sanitize_key() on the
+ // follow-up requests that echo it back.
+ $session_id = uniqid('fallback_');
+ }
// Check if another fallback export is already running
if (file_exists($lock_file)) {
@@ -86,9 +98,9 @@ public function handle_fallback_export() {
// Create or update lock file for this session (only for init step or if no lock exists)
if ($step === 'init' || !file_exists($lock_file)) {
- if (!is_dir($this->filesystem->get_export_dir())) {
- wp_mkdir_p($this->filesystem->get_export_dir());
- }
+ // Always go through the filesystem helper so the directory keeps its
+ // .htaccess/index.php protection.
+ $this->filesystem->create_export_dir();
$lock_data = array(
'session_id' => $session_id,
'time' => time(),
@@ -124,11 +136,9 @@ public function handle_fallback_export() {
$this->set_fallback_exclusion_paths();
}
- // Initialize fallback export directory
+ // Initialize fallback export directory (also (re)applies its access protection)
+ $this->filesystem->create_export_dir();
$export_dir = $this->filesystem->get_export_dir();
- if (!is_dir($export_dir)) {
- wp_mkdir_p($export_dir);
- }
$this->filesystem->log("Fallback export step: $step");
@@ -191,6 +201,11 @@ public function handle_fallback_export() {
* Handle fallback export status check
*/
public function handle_fallback_status() {
+ // 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' ) );
+ }
+
$status_file = $this->filesystem->get_status_file_path();
if (file_exists($status_file)) {
@@ -596,7 +611,6 @@ private function fallback_export_database($params) {
'total_tables' => isset($params['total_tables']) ? (int)$params['total_tables'] : 0,
'rows_exported' => isset($params['rows_exported']) ? (int)$params['rows_exported'] : 0,
'bytes_written' => isset($params['bytes_written']) ? (int)$params['bytes_written'] : 0,
- 'temp_file_path' => isset($params['temp_file_path']) ? $params['temp_file_path'] : null,
);
$this->filesystem->log("Database export will resume with state: " . json_encode($resume_state));
} else {
@@ -649,8 +663,7 @@ private function fallback_export_database($params) {
'table_offset' => isset($result['state']['table_offset']) ? $result['state']['table_offset'] : 0,
'total_tables' => $result['total_tables'],
'rows_exported' => $result['rows_exported'],
- 'bytes_written' => $result['bytes_written'],
- 'temp_file_path' => isset($result['state']['temp_file_path']) ? $result['state']['temp_file_path'] : null
+ 'bytes_written' => $result['bytes_written']
))
);
}
diff --git a/includes/class-filesystem.php b/includes/class-filesystem.php
index 4993cc7..187496c 100644
--- a/includes/class-filesystem.php
+++ b/includes/class-filesystem.php
@@ -69,43 +69,101 @@ public function get_log_file_path() {
}
/**
- * Create the export directory.
+ * Create the export directory and (re)apply its access protection.
+ *
+ * Safe to call repeatedly. The guard files are (re)written whenever they are missing
+ * or out of date, so a directory that was created without them gets healed.
*
* @throws Exception If the directory cannot be created.
*/
public function create_export_dir() {
$dir = $this->get_export_dir();
-
+
if (!file_exists($dir)) {
if (!wp_mkdir_p($dir)) {
throw new Exception('Cannot create export directory: ' . $dir);
}
-
- // Create an index.php file to prevent directory listing
- file_put_contents($dir . '/index.php', "\n" .
- " Order Allow,Deny\n" .
- " Allow from all\n" .
- "\n\n" .
- "# Allow access to status file\n" .
- "\n" .
- " Order Allow,Deny\n" .
- " Allow from all\n" .
- "\n\n" .
- "# Deny access to sensitive files\n" .
- "\n" .
- " Order Allow,Deny\n" .
- " Deny from all\n" .
- "\n";
-
- file_put_contents($dir . '/.htaccess', $htaccess);
}
+
+ $this->protect_export_dir();
+ }
+
+ /**
+ * Write the guard files that keep the export directory from being browsed, executed
+ * or trawled for its non-artifact files (status, step, lock and temp files).
+ *
+ * @return void
+ */
+ public function protect_export_dir() {
+ $dir = $this->get_export_dir();
+
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ // Prevent directory listing on servers that ignore .htaccess.
+ $index_file = $dir . '/index.php';
+ if (!file_exists($index_file)) {
+ @file_put_contents($index_file, "get_htaccess_contents();
+
+ if (!file_exists($htaccess_file) || @file_get_contents($htaccess_file) !== $htaccess) {
+ @file_put_contents($htaccess_file, $htaccess);
+ }
+ }
+
+ /**
+ * Build the .htaccess rules for the export directory.
+ *
+ * Default-deny, with an allow list limited to the randomly-named export artifacts
+ * produced by generate_secure_filename(). The 16 hex character token in the pattern
+ * is what keeps predictable files (export-status.txt, export-step.txt,
+ * fallback_export.lock, db_export_temp_*.sql, anything dropped by a third party)
+ * from being served. Both Apache 2.2 and 2.4 syntaxes are emitted so the rules do
+ * not silently no-op - or 500 - depending on which authz module is loaded.
+ *
+ * @return string The .htaccess contents.
+ */
+ private function get_htaccess_contents() {
+ return "# Hostinger Migrator export directory - do not edit, this file is regenerated.\n" .
+ "\n" .
+ "# Disable directory browsing\n" .
+ "Options -Indexes\n" .
+ "\n" .
+ "# Never execute PHP from this directory (mod_php; other SAPIs are covered by the default deny below)\n" .
+ "\n" .
+ " php_flag engine off\n" .
+ "\n" .
+ "\n" .
+ " php_flag engine off\n" .
+ "\n" .
+ "\n" .
+ " php_flag engine off\n" .
+ "\n" .
+ "\n" .
+ "# Deny everything by default\n" .
+ "\n" .
+ " Require all denied\n" .
+ "\n" .
+ "\n" .
+ " Order Allow,Deny\n" .
+ " Deny from all\n" .
+ "\n" .
+ "\n" .
+ "# Allow only the generated export artifacts\n" .
+ "\n" .
+ " \n" .
+ " Require all granted\n" .
+ " \n" .
+ " \n" .
+ " Order Allow,Deny\n" .
+ " Allow from all\n" .
+ " \n" .
+ "\n";
}
/**
diff --git a/uninstall.php b/uninstall.php
index ab41ef6..03bbbc9 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -19,6 +19,7 @@
function custom_migrator_cleanup() {
// Remove plugin options
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'); // If you kept this option