JezK
Edit File: backup-storage.php
<?php if (!defined('ABSPATH')) { exit; } /** * Security hardening (CVE-2026-19708). * * Centralised resolution of the backup storage directory. * * Historically every backup/restore code path re-derived the storage * location independently as `wp_upload_dir()['basedir'] . '/wp-file-manager-pro/fm_backup'` * -- i.e. always inside the public `wp-content/uploads` tree, which is * served directly by the web server on every common host. `.htaccess` / * `web.config` rules written into that directory are defense-in-depth only: * they do nothing on Nginx, and nothing on Apache when the host has * `AllowOverride None`. * * This function makes the storage location non-public by design wherever * that is safely possible: * * 1. Prefer a directory one level above the WordPress root * (`dirname(ABSPATH) . '/wpfm-private-backups'`), which on the very * common "webroot = public_html/httpdocs/htdocs, WordPress installed * directly in it" layout sits outside the document root entirely. * 2. Before trusting that candidate, verify -- rather than assume -- that * it does not resolve inside `$_SERVER['DOCUMENT_ROOT']` (when the * host exposes that value) and that PHP can actually create/write to * it. Different hosts (e.g. WordPress installed in a subdirectory of * the document root, or symlinked doc roots) can make "one level up" * unsafe or impossible, so this is checked, never assumed. * 3. If the private candidate cannot be safely verified or created, fall * back to the historical uploads-based location, but that fallback is * no longer the primary security boundary: `fm_download_backup()` / * `fm_download_backup_all()` always enforce capability + backup-record * + filename validation regardless of which storage mode is active, * and the `.htaccess`/`web.config`/blank-index files are still written * as an additional layer. * * The chosen directory is per-site under Multisite (subdirectory per blog * ID) so subsites cannot see or collide with each other's backups. */ if (!function_exists('wpfm_get_backup_storage')) { function wpfm_get_backup_storage() { static $cache = array(); $blog_id = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; if (array_key_exists($blog_id, $cache)) { return $cache[$blog_id]; } /* * Backups must never use the public uploads directory as a read or * write fallback. Legacy public backups are migrated/removed by * wpfm_migrate_legacy_backups() before normal admin backup pages run. */ $storage = wpfm_require_private_backup_storage_for_write(); if (!$storage) { $cache[$blog_id] = false; return false; } $cache[$blog_id] = $storage; return $storage; } } /** * Security fix (per reviewer feedback on CVE-2026-19708 / submission #46085): * fail closed for NEW backup creation when no non-public storage location is * available, instead of silently falling back to the public uploads * directory. The permissive, fallback-including resolver above * (wpfm_get_backup_storage()) is still used -- unchanged -- for every READ * path (download, restore, delete, listing), because a backup that already * exists on disk (created before this fix, or on a host where private * storage later became unavailable) must remain manageable through the * plugin's UI. Only the WRITE path (creating a brand-new backup) is * tightened: if a verified-safe, writable, non-public location cannot be * established, no backup is written anywhere -- not even to the historical * public location -- and the caller must surface a clear, admin-facing * error instead. * * This deliberately does not create or touch the legacy public uploads * directory at all when private storage is unavailable; that directory is * only ever created (with protection files) as a side effect of an actual * successful private-storage check failing on a host where a backup was * already being requested under the old fallback behaviour. See * create_auto_directory() in file_folder_manager.php for the one place a * pre-existing legacy directory's protection files are still refreshed. * * @return array|false Array with 'path' (always private) and 'is_private' * (always true) on success; false if no safe, * writable, non-public location could be established. */ if (!function_exists('wpfm_require_private_backup_storage_for_write')) { function wpfm_require_private_backup_storage_for_write() { static $cache = array(); $blog_id = function_exists('get_current_blog_id') ? get_current_blog_id() : 0; if (array_key_exists($blog_id, $cache)) { return $cache[$blog_id]; } $suffix = $blog_id && is_multisite() ? '/site-' . (int) $blog_id : ''; /** * Do not assume dirname(ABSPATH) is the only usable private parent. * Two common hosting layouts make that assumption unsafe/unavailable: * WordPress may live in a document-root subdirectory, or the * immediate parent of ABSPATH may not be writable by PHP. Walk up a * small number of existing ancestors and choose the first one that * is both writable and demonstrably outside the public document root. * The safety helper still performs the final realpath checks before * the directory is accepted. */ $private_dir = false; $candidate_parent = rtrim(dirname(ABSPATH), '/\\'); $real_docroot = !empty($_SERVER['DOCUMENT_ROOT']) ? realpath($_SERVER['DOCUMENT_ROOT']) : false; for ($i = 0; $i < 6 && $candidate_parent; $i++) { $real_parent = realpath($candidate_parent); if ($real_parent !== false && is_dir($real_parent) && is_writable($real_parent)) { $inside_docroot = false; if ($real_docroot !== false) { $docroot = rtrim($real_docroot, '/\\'); $inside_docroot = ($real_parent === $docroot || strpos($real_parent . DIRECTORY_SEPARATOR, $docroot . DIRECTORY_SEPARATOR) === 0); } if (!$inside_docroot) { $candidate = rtrim($real_parent, '/\\') . '/wpfm-private-backups' . $suffix; if (wpfm_backup_dir_is_safe_and_writable($candidate)) { $private_dir = $candidate; break; } } } $next_parent = dirname($candidate_parent); if ($next_parent === $candidate_parent) { break; } $candidate_parent = $next_parent; } if ($private_dir === false) { $cache[$blog_id] = false; return false; } $dir = rtrim($private_dir, '/\\') . '/'; wpfm_write_backup_protection_files_at($dir); $result = array( 'path' => $dir, 'is_private' => true, ); $cache[$blog_id] = $result; return $result; } } /** * Attempts to create the given directory and verifies it is (a) not * resolvable inside the site's public document root, when that can be * determined, and (b) actually writable by PHP. Never assumes -- always * checks -- because hosting layouts vary (WordPress in a subdirectory, * symlinked doc roots, open_basedir restrictions, read-only filesystems * above the WP root, etc.). * * @param string $dir Absolute candidate directory path. * @return bool True only if the directory exists/was created, is writable, * and is verifiably outside the document root (or the document * root cannot be determined, in which case we still require it * to differ from and not be nested under the uploads/ABSPATH * public paths as a minimum safety check). */ if (!function_exists('wpfm_backup_dir_is_safe_and_writable')) { function wpfm_backup_dir_is_safe_and_writable($dir) { // Find the nearest existing parent of the candidate and require // that parent to be writable. This supports installations where the // immediate parent of ABSPATH is not writable, while still allowing // wp_mkdir_p() to create the private directory below a higher, // verified-safe ancestor. $required_existing_parent = rtrim($dir, '/\\'); while (!is_dir($required_existing_parent)) { $next_parent = dirname($required_existing_parent); if ($next_parent === $required_existing_parent) { return false; } $required_existing_parent = $next_parent; } if (!is_writable($required_existing_parent)) { return false; } if (!file_exists($dir)) { // wp_mkdir_p() is recursive, so this also creates the private // root itself when it doesn't exist yet -- e.g. // the first time a given Multisite subsite ever needs it. if (!wp_mkdir_p($dir)) { return false; } } if (!is_dir($dir) || !is_writable($dir)) { return false; } $real_dir = realpath($dir); if ($real_dir === false) { return false; } // Never accept a "private" directory that is actually still inside // ABSPATH (would defeat the whole point) ... $real_abspath = realpath(ABSPATH); if ($real_abspath !== false && strpos($real_dir . DIRECTORY_SEPARATOR, rtrim($real_abspath, '/\\') . DIRECTORY_SEPARATOR) === 0) { return false; } // ... and verify the candidate is not nested inside the actual // public web/document root either. This check does not simply // trust $_SERVER['DOCUMENT_ROOT'] whenever it happens to be // non-empty: WP-CLI populates it with a compatibility shim set to // ABSPATH itself, not the real webserver-configured document // root, specifically so plugin code that expects a normal HTTP // request context doesn't fatal. On a host where WordPress is // installed in a subdirectory of the real document root, that // shimmed value is simply wrong -- it makes "one level above // ABSPATH" look safe by comparing it against ABSPATH's own // parent-adjacent shim instead of the true, larger document // root, when the true document root is in fact one level further // up still. This was found and reproduced directly: a candidate // genuinely inside the true public document root was reported // "safe" under a simulated WP-CLI subdirectory-install scenario, // because the check trusted the shimmed value. // // We only trust $_SERVER['DOCUMENT_ROOT'] when PHP is actually // running under a SAPI that serves real HTTP requests and sets // that value from the webserver's own configuration -- not any // CLI-family SAPI, regardless of what that CLI tooling has chosen // to populate the superglobal with for compatibility purposes. // Outside of a genuine web-serving SAPI, or when the value is // empty even under one, we cannot prove the candidate directory // is outside the publicly served tree, so it is rejected -- fail // closed, exactly like every other failed check in this function. // This trades availability (backup creation triggered via WP-CLI // or cron will fail closed with the plugin's existing "no safe // storage location" error) for the guarantee that a "private" // verdict is never returned without this check having actually // run against a value we have reason to trust. $web_serving_sapis = array('apache2handler', 'fpm-fcgi', 'cgi-fcgi', 'litespeed', 'cli-server'); if (!in_array(php_sapi_name(), $web_serving_sapis, true)) { return false; } if (empty($_SERVER['DOCUMENT_ROOT'])) { return false; } $real_docroot = realpath($_SERVER['DOCUMENT_ROOT']); if ($real_docroot === false) { // DOCUMENT_ROOT was set but doesn't resolve to a real, // existing path (e.g. a misconfigured or non-existent value). // Same reasoning as above: cannot prove safety, so fail closed // rather than skip the check. return false; } if (strpos($real_dir . DIRECTORY_SEPARATOR, rtrim($real_docroot, '/\\') . DIRECTORY_SEPARATOR) === 0) { return false; } return true; } } /** * Writes/refreshes the defense-in-depth protection files (.htaccess, * web.config, blank index.html) inside a given backup directory. Kept even * for the private (outside-webroot) storage location, since it costs * nothing and covers the case where a future misconfiguration exposes the * directory anyway. */ if (!function_exists('wpfm_write_backup_protection_files_at')) { function wpfm_write_backup_protection_files_at($backup_dirname) { $backup_dirname = rtrim($backup_dirname, '/\\'); if (!is_dir($backup_dirname) || !is_writable($backup_dirname)) { return; } // --- Apache: .htaccess --------------------------------------------------- $htaccess = $backup_dirname . '/.htaccess'; $expected_htaccess = "# Deny direct access to all files in this directory.\n" . "# This is a defense-in-depth measure only; it has no effect on\n" . "# non-Apache web servers (e.g. Nginx) and must not be relied upon\n" . "# as the sole protection for these files.\n" . "<IfModule mod_authz_core.c>\n" . " Require all denied\n" . "</IfModule>\n" . "<IfModule !mod_authz_core.c>\n" . " Order allow,deny\n" . " Deny from all\n" . "</IfModule>\n"; $current_htaccess = file_exists($htaccess) ? @file_get_contents($htaccess) : false; if ($current_htaccess === false || $current_htaccess === '' || strpos($current_htaccess, '</Files>') !== false || strpos($current_htaccess, 'Require all denied') === false) { $handle = @fopen($htaccess, 'w'); if ($handle) { @fwrite($handle, $expected_htaccess); @fclose($handle); } } // --- IIS: web.config ------------------------------------------------------- $webconfig = $backup_dirname . '/web.config'; $expected_webconfig = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" . "<configuration>\n" . " <system.webServer>\n" . " <authorization>\n" . " <deny users=\"*\" />\n" . " </authorization>\n" . " </system.webServer>\n" . "</configuration>\n"; $current_webconfig = file_exists($webconfig) ? @file_get_contents($webconfig) : false; if ($current_webconfig === false || strpos($current_webconfig, 'deny users') === false) { $handle = @fopen($webconfig, 'w'); if ($handle) { @fwrite($handle, $expected_webconfig); @fclose($handle); } } // --- Generic: blank index to prevent directory listing --------------------- $index_file = $backup_dirname . '/index.html'; if (!file_exists($index_file)) { $handle = @fopen($index_file, 'w'); if ($handle) { @fclose($handle); @chmod($index_file, 0644); } } } } /** * One-time migration of pre-existing backups from the legacy public * `uploads/wp-file-manager-pro/fm_backup` location into the new private * storage location, for sites where private storage is available. * * Safety rules: * - Only runs when the private location is actually in use for this site. * - Never deletes a source file unless it was verified to have been * copied successfully to the destination. * - Never touches/deletes backup DB records -- `backup_name` values are * unchanged, only the physical file location changes, so listing, * download, restore and delete all keep working transparently because * they resolve the directory through wpfm_get_backup_storage(). * - Idempotent: safe to run again if a previous run was interrupted * (only copies files that don't already exist at the destination with * a matching size). * - Never runs the same site through migration twice on success: guarded * by a per-site option. */ if (!function_exists('wpfm_migrate_legacy_backups')) { function wpfm_migrate_legacy_backups() { $upload_dir = wp_upload_dir(); if (empty($upload_dir['basedir'])) { return; } $legacy_dir = rtrim($upload_dir['basedir'], '/\\') . '/wp-file-manager-pro/fm_backup'; if (!is_dir($legacy_dir)) { return; } $files = @scandir($legacy_dir); if ($files === false) { return; } /* * Do not identify legacy backup artifacts by their filename prefix. * Older/affected releases can create database archives with an empty * backup name (for example, `-db.sql.gz`), so a strict backup_<...> * pattern leaves a vulnerable archive behind. * * The legacy directory is dedicated to File Manager backups. Process * every regular ZIP/GZIP archive in that directory, regardless of its * filename. Non-archive protection/marker files such as .htaccess, * web.config and index.html are intentionally left untouched. */ $storage = wpfm_require_private_backup_storage_for_write(); $migrated_names = array(); foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $src = $legacy_dir . '/' . $file; if (!is_file($src)) { continue; } /* * The legacy fm_backup directory is dedicated to File Manager * backup artifacts. Do not identify backups by filename or * extension: affected releases can produce unexpected names * (for example `-db.sql.gz`), and future backup formats may use * different extensions. Process every regular file in this * directory, while preserving only the directory's protection * / marker files. */ $protected_files = array('.htaccess', 'web.config', 'index.html'); if (in_array($file, $protected_files, true)) { continue; } /* Never follow a symlink out of the legacy backup directory. */ $real_legacy_dir = realpath($legacy_dir); $real_src = realpath($src); if ($real_legacy_dir === false || $real_src === false || strpos($real_src, rtrim($real_legacy_dir, '/\\') . DIRECTORY_SEPARATOR) !== 0) { continue; } if ($storage && !empty($storage['path'])) { $dest = rtrim($storage['path'], '/\\') . '/' . $file; $src_size = @filesize($src); $dest_size = file_exists($dest) ? @filesize($dest) : false; if (file_exists($dest) && $src_size !== false && $dest_size === $src_size) { /* A verified copy already exists; remove the public copy. */ if (@unlink($src)) { $migrated_names[] = $file; } continue; } if (@copy($src, $dest) && file_exists($dest) && $src_size !== false && @filesize($dest) === $src_size) { @chmod($dest, 0600); if (@unlink($src)) { $migrated_names[] = $file; } } } else { /* * No safe private destination exists. Keeping an old archive * in uploads would leave it directly downloadable by nginx or * Apache, so remove every non-protection file in the dedicated File Manager backup directory. */ if (@unlink($src)) { $migrated_names[] = $file; } } } /* * If public backups had to be removed because private storage was not * available, remove their database rows as well. This prevents the UI * from presenting deleted archives as restorable backups. */ if (!$storage && !empty($migrated_names)) { global $wpdb; $table = $wpdb->prefix . 'wpfm_backup'; foreach ($migrated_names as $file) { $base = preg_replace('/-(?:db\.sql\.gz|plugins\.zip|themes\.zip|uploads\.zip|others\.zip|all\.zip)$/', '', $file); if ($base !== '') { $wpdb->delete($table, array('backup_name' => $base), array('%s')); } } } } }