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