constant name */ function wpbr_pinned_dir_constants() { return array( 'staging' => 'WPBR_STAGING_DIR', 'backups' => 'WPBR_BACKUPS_DIR', ); } /** * Validate a path supplied by a wp-config.php constant. * * Deliberately weaker than wpbr_validate_custom_dir(): a constant may point * outside wp-content, and outside the web root entirely. That is the point of * it. Editing wp-config.php is a code-level change made by whoever deploys the * site, not something an admin session can do, so the wp-content restriction * that protects the settings field would only be in the way here. Putting * backups outside the web root is better than any deny rule inside uploads. * * What is still checked is that the value is usable: absolute, and free of * traversal segments that would make the resolved location depend on the * current working directory. The folder itself need not exist yet. * * @return string The normalized path, or '' when the value cannot be used. */ function wpbr_validate_pinned_dir($candidate) { $candidate = trim((string) $candidate); if ($candidate === '') return ''; $candidate = rtrim(str_replace('\\', '/', $candidate), '/'); if (strpos($candidate, '..') !== false) return ''; // Checked after the trailing slash comes off, so "/" and "C:/" - which // trim down to nothing addressable - are refused along with relative // paths. Neither is ever a working folder. if (!wpbr_path_is_absolute($candidate)) return ''; return $candidate; } /** * The pinned path for a folder, or '' when nothing usable is pinned. * * @param string $which 'staging' or 'backups' */ function wpbr_pinned_dir($which) { $constants = wpbr_pinned_dir_constants(); if (!isset($constants[$which])) return ''; $name = $constants[$which]; if (!defined($name)) return ''; $dir = wpbr_validate_pinned_dir(constant($name)); if ($dir === '') return ''; if (!is_multisite()) return $dir; return wpbr_pinned_dir_for_site($dir, get_current_blog_id(), is_main_site()); } /** * A pinned folder, made safe to share on a Multisite network. * * A constant in wp-config.php is one value for the whole network, so with it * every site would stage and back up into the same folder, where two sites' * files and backups could collide or be read as each other's. The main site * keeps the pinned folder exactly as written - its backups are already there and * must not appear to vanish - and every other site gets its own child folder. * Single-site installs are the main-site case and are unchanged. * * @param string $dir A folder already accepted by wpbr_validate_pinned_dir(). * @param int $blog_id The site asking. * @param bool $is_main True for the main site, or any non-Multisite install. * @return string */ function wpbr_pinned_dir_for_site($dir, $blog_id, $is_main) { return $is_main ? $dir : $dir . '/site-' . (int) $blog_id; } /** * True when a folder is fixed by wp-config.php rather than by the setting. * * Used to explain in the UI why the field is being ignored. False when the * constant is defined but unusable, because in that case the setting is back * in charge and the field must stay editable. */ function wpbr_dir_is_pinned($which) { return wpbr_pinned_dir($which) !== ''; } /** * What is wrong with a pinned folder, for reporting in the admin. * * @return array{constant:string, raw:string, path:string, problem:string} * problem is '' when nothing is wrong, or one of: * 'unusable' - not absolute, or contains traversal segments * 'not_writable' - resolves fine but cannot be written to */ function wpbr_pinned_dir_status($which) { $constants = wpbr_pinned_dir_constants(); $name = isset($constants[$which]) ? $constants[$which] : ''; $status = array('constant' => $name, 'raw' => '', 'path' => '', 'problem' => ''); if ($name === '' || !defined($name)) return $status; $status['raw'] = (string) constant($name); if (trim($status['raw']) === '') return $status; // Defined empty reads as "not pinned". $path = wpbr_validate_pinned_dir($status['raw']); if ($path === '') { $status['problem'] = 'unusable'; return $status; } $status['path'] = $path; // Existing folders must be writable; folders yet to be created need a // writable parent, since that is where wp_mkdir_p() will start. if (is_dir($path)) { if (!wp_is_writable($path)) $status['problem'] = 'not_writable'; } else { $probe = $path; while ($probe !== '' && !file_exists($probe) && strpos($probe, '/') !== false) { $probe = substr($probe, 0, strrpos($probe, '/')); } if ($probe === '' || !is_dir($probe) || !wp_is_writable($probe)) { $status['problem'] = 'not_writable'; } } return $status; } /** * Folder resolution, in order of precedence: * * 1. A constant in wp-config.php (WPBR_STAGING_DIR / WPBR_BACKUPS_DIR), * which may point anywhere, including outside the web root. * 2. The admin setting, restricted to inside wp-content. * 3. The default inside uploads. * * A constant that fails wpbr_validate_pinned_dir() falls through to the * setting rather than taking the plugin offline, and wpbr_admin_warnings() * reports it. */ function wpbr_staging_dir() { $upload_dir = wp_upload_dir(); $dir = $upload_dir['basedir'] . '/wpbr-staging'; $pinned = wpbr_pinned_dir('staging'); if ($pinned !== '') { $dir = $pinned; } elseif (function_exists('wpbr_get_setting')) { $custom = wpbr_validate_custom_dir(wpbr_get_setting('staging_dir', '')); if ($custom) $dir = $custom; } return apply_filters('wpbr_staging_dir', $dir); } function wpbr_backups_dir() { $upload_dir = wp_upload_dir(); $dir = $upload_dir['basedir'] . '/wpbr-backups'; // wp-config pin, then a recorded private location (wpbr_storage_activate()), then the admin setting, then the default. // With no location recorded this is the order it has always had. $pinned = wpbr_pinned_dir('backups'); $active = $pinned === '' && function_exists('wpbr_storage_active_dir') ? wpbr_storage_active_dir() : ''; if ($pinned !== '') { $dir = $pinned; } elseif ($active !== '') { $dir = $active; } elseif (function_exists('wpbr_get_setting')) { $custom = wpbr_validate_custom_dir(wpbr_get_setting('backups_dir', '')); if ($custom) $dir = $custom; } return apply_filters('wpbr_backups_dir', $dir); } /** * Pinned folders that sit outside wp-content, and so survive uninstall. * * Keyed by a human label, because the only consumers are the settings screen * and the help text, where the path has to be named so the admin knows what * is left behind. * * A path that cannot be resolved at all counts as external: the disclosure is * only wrong in the direction of mentioning a folder uninstall would have * removed anyway. * * @return array label => path */ function wpbr_externally_pinned_dirs() { $out = array(); $labels = array( 'staging' => __('staging folder', 'wpbr-bulk-replace'), 'backups' => __('backups folder', 'wpbr-bulk-replace'), ); foreach ($labels as $which => $label) { $path = wpbr_pinned_dir($which); if ($path === '') continue; if (wpbr_validate_custom_dir($path) !== '') continue; // inside wp-content; uninstall handles it $out[$label] = $path; } // A private location chosen on the Settings screen is outside wp-content too, and is kept on uninstall the same way. if (function_exists('wpbr_storage_active_dir') && wpbr_storage_active_dir() !== '') { $registry = wpbr_storage_registry(); $out[__('private backups location', 'wpbr-bulk-replace')] = !empty($registry['active']['root']) ? $registry['active']['root'] : wpbr_storage_active_dir(); } return $out; } /** * The capitalised display name of one of the two directories. * * Distinct from the lowercase mid-sentence form above: this one opens a * sentence, and in several languages that is a different word, not the same * word with a different first letter. Both forms are therefore translated * separately rather than derived from each other. * * @param string $which 'staging' or 'backups' */ function wpbr_dir_display_name($which) { $names = array( /* translators: the name of the staging folder, capitalised because it opens a sentence such as "Staging folder is not writable." */ 'staging' => _x('Staging', 'folder name, sentence-initial', 'wpbr-bulk-replace'), /* translators: the name of the backups folder, capitalised because it opens a sentence such as "Backups folder is not writable." */ 'backups' => _x('Backups', 'folder name, sentence-initial', 'wpbr-bulk-replace'), ); return isset($names[$which]) ? $names[$which] : $which; } /** * May uninstall recursively remove this directory? * * Uninstall is the one operation with no undo and no observer: it runs after * the plugin files are gone, on a site nobody is watching. So the answer is * yes only for a directory demonstrably inside wp-content. * * That deliberately excludes a folder pinned outside wp-content by * wp-config.php. The plugin will happily create, write and read there during * normal operation - a pinned folder is the supported way to keep backups off * the web root - but uninstall does not follow it. A constant that turns * "delete the plugin" into a recursive delete of an arbitrary server path is * worth more to an attacker than the tidiness is worth to anyone else, and * removing a folder the admin chose and placed themselves is exactly the case * where asking them to do it by hand costs little. * * Symlinks resolve before the comparison, so a link inside wp-content that * points elsewhere is refused on where it leads, not where it sits. */ function wpbr_dir_is_removable_on_uninstall($dir) { if (!$dir || !is_dir($dir)) return false; // A private location is preserved on uninstall whatever else is true of it. if (function_exists('wpbr_storage_dir_is_preserved') && wpbr_storage_dir_is_preserved($dir)) return false; $real = realpath($dir); $content = defined('WP_CONTENT_DIR') ? realpath(WP_CONTENT_DIR) : false; if (!$real || !$content) return false; $real = rtrim(str_replace('\\', '/', $real), '/'); $content = rtrim(str_replace('\\', '/', $content), '/'); // Strictly inside, never wp-content itself. return $real !== $content && strpos($real, $content . '/') === 0; } /** * Deny direct HTTP access to a working folder. * * By default both folders live under uploads, which is web-readable, and they * hold working copies and backups of live documents. The rules are written * even for a folder pinned outside the web root, where they are inert: a * folder can be moved back inside later, and a rule that was never written is * the one nobody notices is missing. */ function wpbr_protect_dir($dir) { if (!is_dir($dir)) return false; $htaccess = $dir . '/.htaccess'; if (!file_exists($htaccess)) { $rules = "# Added by WP Bulk Replace. These files are working copies\n"; $rules .= "# and backups of live documents and must not be web-readable.\n"; $rules .= "\n Require all denied\n\n"; $rules .= "\n Order allow,deny\n Deny from all\n\n"; @file_put_contents($htaccess, $rules); } $index = $dir . '/index.php'; if (!file_exists($index)) { @file_put_contents($index, " 300) { wp_delete_file($stale); } } $webconfig = $dir . '/web.config'; if (!file_exists($webconfig)) { @file_put_contents($webconfig, "\n" ."" ."\n"); } // Whether the three files are really there, as files. Most callers rely on the rules being a courtesy // for a folder that is private anyway; a caller that is about to PUT something in the folder because of // them (the site-deletion rescue) must be able to refuse when they could not be written. return is_file($htaccess) && is_file($index) && is_file($webconfig); } function wpbr_ensure_folders() { $staging = wpbr_staging_dir(); $backups = wpbr_backups_dir(); wp_mkdir_p($staging); wp_mkdir_p($backups); wpbr_protect_dir($staging); wpbr_protect_dir($backups); } /** * Everything before the first `?` or `#`. * * What every path lookup should be handed. A URL is not a path: appending a * query string to a file URL must never change which file it names. */ function wpbr_url_without_decoration($url) { $url = (string) $url; return substr($url, 0, strcspn($url, '?#')); } /** * Resolve a stored file URL to an absolute path inside the uploads directory. * * Deliberately ignores scheme and host: URLs saved before an HTTP->HTTPS * migration (or under an old domain) will not match wp_upload_dir()['baseurl'] * by string comparison, so matching on the /uploads/ path segment instead is * the only reliable approach. * * Returns an absolute path, or '' if the URL doesn't look like an upload. */ function wpbr_url_to_upload_path($url) { $path = wpbr_url_to_local_upload_path($url); /** * Where the original of this URL really lives, when it is not a file in the uploads folder. * * For a storage module that keeps the authoritative original in remote storage it owns (Cloud Storage). Return a reference * (a `scheme://...` string) that an adapter registered with `wpbr_storage_adapters` claims, or '' to leave the local path alone. * A reference that no registered adapter claims, or that is not a stream-style reference, is ignored. The engine then takes the * adapter route for the row (includes/storage-remote.php); nothing here writes anything. * * @param string $ref '' by default. * @param string $url The stored file URL. * @param string $path What the local mapping gave (may be ''). */ $ref = (string) apply_filters('wpbr_remote_original_ref', '', (string) $url, (string) $path); if ($ref !== '' && wpbr_ref_is_stream_uri($ref) && function_exists('wpbr_storage_adapter_for') && wpbr_storage_adapter_for($ref)) { return $ref; } return $path; } /** The local mapping of a stored URL: the file in the uploads folder it points at, or ''. */ function wpbr_url_to_local_upload_path($url) { $url = trim((string) $url); if ($url === '') return ''; $upload_dir = wp_upload_dir(); $basedir = rtrim($upload_dir['basedir'], '/'); // Path component only - drops scheme, host, query and fragment. $path = wp_parse_url($url, PHP_URL_PATH); if (!$path) $path = $url; $path = urldecode($path); // Everything after the uploads folder name, wherever it appears. $base_leaf = basename($basedir); // normally "uploads" // ON A MULTISITE SUBSITE basedir ENDS IN sites/, so its last segment is a // number and not the folder URLs mention. Anchoring on that number matched the // LAST "/10/" in the path, and a month directory is also a number: blog 10 in // October resolved .../sites/10/2026/10/a.pdf to .../sites/10/a.pdf. Anchor on // the uploads root instead, then drop this site's own sites// prefix. $own_prefix = ''; if (is_multisite() && preg_match('#/(sites/\d+)$#', str_replace('\\', '/', $basedir), $own)) { $base_leaf = basename(dirname($basedir, 2)); $own_prefix = $own[1] . '/'; } $needle = '/' . $base_leaf . '/'; $pos = strrpos($path, $needle); if ($pos !== false) { $relative = substr($path, $pos + strlen($needle)); if ($own_prefix !== '' && strpos($relative, $own_prefix) === 0) { $relative = substr($relative, strlen($own_prefix)); } } else { // Not obviously an upload URL - fall back to the old behaviour. On the // URL WITHOUT its query string and fragment: a link carrying // `?updated_on=...` (or anything else after the file name) is still the // same file, and what follows the name must never become part of a path. $bare = urldecode(wpbr_url_without_decoration($url)); $relative = ltrim(str_replace($upload_dir['baseurl'], '', $bare), '/'); if ($relative === $bare) return ''; } $relative = ltrim($relative, '/'); if ($relative === '') return ''; return $basedir . '/' . $relative; } /** * True when $path sits inside the uploads directory. Guards against a bad * stored value being used to read or write somewhere it shouldn't. */ function wpbr_path_is_in_uploads($path) { return wpbr_path_is_within(wp_upload_dir()['basedir'], $path); } /** * True when $path is $base itself or sits beneath it. * * Compares with a trailing separator - a bare prefix test would let * /var/www/uploads-old pass for a base of /var/www/uploads. */ function wpbr_path_is_within($base, $path) { $real_base = realpath($base); if (!$real_base) return false; $real_base = rtrim(str_replace('\\', '/', $real_base), '/'); $target = file_exists($path) ? realpath($path) : realpath(dirname($path)); if (!$target) return false; $target = rtrim(str_replace('\\', '/', $target), '/'); return ($target === $real_base) || strpos($target, $real_base . '/') === 0; } /** * A readable label for a user. * * Uses display_name, falling back to the username if it isn't set. The username * and email go in the tooltip via wpbr_user_title(). */ function wpbr_user_label($user_id) { if (!$user_id) return ''; $user = get_userdata($user_id); if (!$user) return 'user ' . intval($user_id) . ' (deleted)'; $name = trim((string) $user->display_name); return $name !== '' ? $name : $user->user_login; } /** * Fuller description for a tooltip: real name and email where known. */ function wpbr_user_title($user_id) { if (!$user_id) return ''; $user = get_userdata($user_id); if (!$user) return ''; $bits = array(); $full = trim($user->first_name . ' ' . $user->last_name); if ($full !== '' && $full !== trim((string) $user->display_name)) $bits[] = $full; $bits[] = $user->user_login; if ($user->user_email) $bits[] = $user->user_email; return implode(' - ', $bits); } /** * Neutralise spreadsheet formulas before writing a value to CSV. * * The export exists to be opened in Excel by a reviewer, so a value beginning * =, +, - or @ would be evaluated as a formula. Prefixing a tab keeps the text * readable while forcing it to be treated as text. */ function wpbr_csv_safe($value) { $value = (string) $value; if ($value === '') return $value; if (preg_match('/^[=+\-@\t\r]/', $value)) { return "\t" . $value; } return $value; } /** * The URL a directory would be served from, when that can be established. * * Only three mappings are confident enough to build a URL from, and each one * is a place WordPress itself already tells us the URL of: * * 1. Inside the uploads directory - wp_upload_dir() gives path and URL as a * matched pair, so the mapping is exact. * 2. Inside ABSPATH - site_url() is the URL of ABSPATH by definition. * 3. Inside DOCUMENT_ROOT, when ABSPATH is inside it too. The second half * matters: it is what makes this the document root that serves this site * rather than some other vhost's, so the site's own scheme and host can * be borrowed for a folder beside the install. * * Anything else returns ''. That is not the same as "not web-reachable" - * DOCUMENT_ROOT is routinely absent under CLI and cron, aliases and rewrites * can serve a path that maps to no root at all, and a reverse proxy can put * the served root on a different machine entirely. The caller must treat '' * as "cannot tell", never as "safe": a folder outside ABSPATH may still sit * under the document root and be served exactly like any other file. With * ABSPATH at /var/www/html/wordpress, /var/www/html/wpbr-backups is outside * the install and fully public. * * @return string URL with no trailing slash, or '' when it cannot be established. */ function wpbr_dir_public_url_base($dir) { $real_dir = rtrim(str_replace('\\', '/', (string) realpath($dir)), '/'); if ($real_dir === '') return ''; // Separator-aware throughout: a bare prefix test would call // /uploads-external "inside uploads" and then build a nonsense URL from // it, and a 404 on a nonsense URL reads as a pass. $upload_dir = wp_upload_dir(); if (wpbr_path_is_within($upload_dir['basedir'], $real_dir)) { return untrailingslashit($upload_dir['baseurl']) . wpbr_relative_url_path($real_dir, $upload_dir['basedir']); } if (wpbr_path_is_within(ABSPATH, $real_dir)) { return untrailingslashit(site_url()) . wpbr_relative_url_path($real_dir, ABSPATH); } $doc_root = wpbr_server_document_root(); if ($doc_root === '') return ''; if (!wpbr_path_is_within($doc_root, ABSPATH)) return ''; // not the root serving this site if (!wpbr_path_is_within($doc_root, $real_dir)) return ''; // Path dropped: we are mapping from the document root, so a site installed // in a subdirectory must not have that subdirectory in the URL. $site = wp_parse_url(site_url()); if (empty($site['scheme']) || empty($site['host'])) return ''; $origin = $site['scheme'] . '://' . $site['host'] . (empty($site['port']) ? '' : ':' . $site['port']); return $origin . wpbr_relative_url_path($real_dir, $doc_root); } /** * The web server's document root, or '' when it cannot be trusted. * * A server variable can be shaped by whoever sent the request, so it is cleaned * like any other input, as it is read. A real document root is a plain absolute * path, which the sanitizer leaves alone; anything it has to change (markup, a * percent escape, a control character) either stops resolving to a directory or * resolves to a different one, and callers still require the result to contain * both the WordPress install and the folder in question, so a value that is not * this site's real root is discarded rather than used. */ function wpbr_server_document_root() { $clean = isset($_SERVER['DOCUMENT_ROOT']) && is_string($_SERVER['DOCUMENT_ROOT']) ? sanitize_text_field(wp_unslash($_SERVER['DOCUMENT_ROOT'])) : ''; if ($clean === '') return ''; $real = realpath($clean); return $real === false ? '' : rtrim(str_replace('\\', '/', $real), '/'); } /** * The part of $path below $base, as a URL path fragment ('' or '/a/b'). */ function wpbr_relative_url_path($path, $base) { $base = rtrim(str_replace('\\', '/', (string) realpath($base)), '/'); $relative = ltrim(substr($path, strlen($base)), '/'); if ($relative === '') return ''; $parts = array(); foreach (explode('/', $relative) as $segment) { $parts[] = rawurlencode($segment); } return '/' . implode('/', $parts); } /** * The folders whose exposure is measured: where copies of documents are kept. * * The backups folder holds the files that were replaced. The staging folder * holds the files about to replace them - content that is not published yet, * under the same uploads tree and behind the same protection files, so it is * exactly as exposed as backups when those files are inert. * * @return string[] */ function wpbr_exposure_purposes() { return array('backups', 'staging'); } /** * The folder a purpose refers to, as the plugin resolves it right now. */ function wpbr_exposure_dir($which) { return $which === 'staging' ? wpbr_staging_dir() : wpbr_backups_dir(); } /** * What a cached verdict belongs to: this site, this folder, this purpose. * * A verdict is a statement about one URL space and one folder. It used to be * kept under a single key, so a "protected" measured for one folder was * reported for whichever folder the plugin was pointed at next - a pinned * constant added, a setting changed, a site cloned to another host. Keyed by * all three, a verdict is only ever found by the thing it measured. * * The site address is part of the key as well as the per-site storage a * Multisite network already gives a transient: a database copied to another * host, or a persistent object cache shared between sites, must not carry a * verdict across. */ function wpbr_exposure_cache_key($which, $dir) { $real = realpath($dir); $path = rtrim(str_replace('\\', '/', $real === false ? (string) $dir : $real), '/'); return 'v2:' . sha1(strtolower(untrailingslashit(site_url())) . '|' . $which . '|' . $path); } /** * The verdict cache, as a list keyed by wpbr_exposure_cache_key(). * * One transient, as before (wpbr_exposure_check is in the uninstall inventory * and nothing about that changes), holding a version and the verdicts. Anything * that is not that shape - the single verdict earlier releases stored, or one * a demo seed wrote - is read as an empty cache, so it can never be mistaken for * a measurement of the current folder. Each entry carries its own time and is * judged by it, because rewriting the transient for one folder must not extend * the life of another folder's verdict. * * @return array */ function wpbr_exposure_cache_read() { $stored = get_transient('wpbr_exposure_check'); if (!is_array($stored) || !isset($stored['v']) || (int) $stored['v'] !== 3 || !isset($stored['verdicts']) || !is_array($stored['verdicts'])) { return array(); } $fresh = array(); foreach ($stored['verdicts'] as $key => $verdict) { if (is_array($verdict) && !empty($verdict['status']) && !empty($verdict['checked_at']) && (time() - (int) $verdict['checked_at']) <= DAY_IN_SECONDS) { $fresh[$key] = $verdict; } } return $fresh; } /** * Words for a result, for one folder. * * The backups sentences are the ones that have always been shown. The staging * ones say the same thing about the other folder; they are spelled out rather * than built by substituting a noun, because the noun's place in a sentence * differs between languages. * * @return array */ function wpbr_exposure_messages($which) { if ($which === 'staging') { return array( 'missing' => __('Staging folder does not exist yet, so there is nothing to expose.', 'wpbr-bulk-replace'), 'unresolved' => __('Staging folder path could not be resolved on disk.', 'wpbr-bulk-replace'), 'unmappable' => __('The staging folder is outside the WordPress installation path, so WPBR Bulk Replace cannot determine whether your web server exposes it. Verify the folder is outside the server’s document root or otherwise denied over HTTP.', 'wpbr-bulk-replace'), 'unwritable' => __('Could not write a test file into the staging folder, so exposure could not be measured.', 'wpbr-bulk-replace'), /* translators: %s is an error message from the HTTP request. */ 'http_error' => __('Could not complete the check: %s', 'wpbr-bulk-replace'), 'exposed' => __('A test file in the staging folder was readable over the web (HTTP 200).', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code, e.g. 403. */ 'protected' => __('A test file in the staging folder was not readable over the web (HTTP %s).', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code. */ 'unexpected' => __('Unexpected response (HTTP %s), so exposure could not be confirmed either way.', 'wpbr-bulk-replace'), 'control_unwritable' => __('A test file in the staging folder was not readable, but WPBR Bulk Replace could not place a second test file in its own folder in your uploads directory to check that the address it used really reaches that folder. Exposure could not be determined.', 'wpbr-bulk-replace'), 'control_outside_uploads' => __('A test file in the staging folder was not readable, but the folder is not inside your uploads directory, and WPBR Bulk Replace writes nothing outside its own folders, so it cannot check that the address it used really reaches the folder. Exposure could not be determined. Keep the folder in the uploads directory, pin it outside the web root, or attest to it in wp-config.php.', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code. */ 'control_failed' => __('A test file in the staging folder was not readable, and neither was a second test file placed in its own folder in your uploads directory (HTTP %s). The address used for the check may not reach the folder at all - a CDN, proxy or rewrite rule may be answering instead - so this does not show the folder is private.', 'wpbr-bulk-replace'), /* translators: %s is an error message from the HTTP request. */ 'control_error' => __('A test file in the staging folder was not readable, but the check that the address reaches the folder could not be completed: %s', 'wpbr-bulk-replace'), ); } return array( 'missing' => __('Backups folder does not exist yet, so there is nothing to expose.', 'wpbr-bulk-replace'), 'unresolved' => __('Backups folder path could not be resolved on disk.', 'wpbr-bulk-replace'), 'unmappable' => __('The backups folder is outside the WordPress installation path, so WPBR Bulk Replace cannot determine whether your web server exposes it. Verify the folder is outside the server’s document root or otherwise denied over HTTP.', 'wpbr-bulk-replace'), 'unwritable' => __('Could not write a test file into the backups folder, so exposure could not be measured.', 'wpbr-bulk-replace'), /* translators: %s is an error message from the HTTP request. */ 'http_error' => __('Could not complete the check: %s', 'wpbr-bulk-replace'), 'exposed' => __('A test file in the backups folder was readable over the web (HTTP 200).', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code, e.g. 403. */ 'protected' => __('A test file in the backups folder was not readable over the web (HTTP %s).', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code. */ 'unexpected' => __('Unexpected response (HTTP %s), so exposure could not be confirmed either way.', 'wpbr-bulk-replace'), 'control_unwritable' => __('A test file in the backups folder was not readable, but WPBR Bulk Replace could not place a second test file in its own folder in your uploads directory to check that the address it used really reaches that folder. Exposure could not be determined.', 'wpbr-bulk-replace'), 'control_outside_uploads' => __('A test file in the backups folder was not readable, but the folder is not inside your uploads directory, and WPBR Bulk Replace writes nothing outside its own folders, so it cannot check that the address it used really reaches the folder. Exposure could not be determined. Keep the folder in the uploads directory, pin it outside the web root, or attest to it in wp-config.php.', 'wpbr-bulk-replace'), /* translators: %s is an HTTP status code. */ 'control_failed' => __('A test file in the backups folder was not readable, and neither was a second test file placed in its own folder in your uploads directory (HTTP %s). The address used for the check may not reach the folder at all - a CDN, proxy or rewrite rule may be answering instead - so this does not show the folder is private.', 'wpbr-bulk-replace'), /* translators: %s is an error message from the HTTP request. */ 'control_error' => __('A test file in the backups folder was not readable, but the check that the address reaches the folder could not be completed: %s', 'wpbr-bulk-replace'), ); } /** * The plugin's own public folder for the exposure control, inside the uploads directory. * * Named for the plugin's slug, resolved at run time from wp_upload_dir() (never a fixed path), and the only place the * control is ever written. It is meant to be public: a control is a file the web server has to be able to serve, and * its content is a random token that is worthless a moment later. * * @return array{dir:string,url:string}|null Null when the uploads directory is unusable. */ function wpbr_exposure_control_location() { $uploads = wp_upload_dir(null, false); if (!is_array($uploads) || !empty($uploads['error']) || empty($uploads['basedir']) || empty($uploads['baseurl'])) { return null; } $slug = sanitize_key(basename(untrailingslashit((string) WPBR_PLUGIN_DIR))); if ($slug === '') { return null; } return array( 'dir' => rtrim(str_replace('\\', '/', (string) $uploads['basedir']), '/') . '/' . $slug, 'url' => untrailingslashit((string) $uploads['baseurl']) . '/' . $slug, ); } /** * Does the address used for the probe reach this part of the filesystem? * * Writes a control file into the plugin's OWN folder in the uploads directory (wpbr_exposure_control_location()), * asks for it at that folder's address the way the protected folder's own test file was asked for, and removes the file * again (the empty folder stays and is removed on uninstall). Verified only when the response is 200 and carries the random token written into that file. See * wpbr_check_dir_exposure() for why a refusal is not believed without this. * * NOTHING IS WRITTEN OUTSIDE THE PLUGIN'S OWN FOLDERS. The control no longer sits in the protected folder's parent, * which could be wp-content or the uploads root itself. It can still stand for the protected folder because both * addresses come from the same pair, the uploads directory and its URL (wp_upload_dir()), so a server that answers * for one answers for the other; a CDN, proxy or rewrite that 404s everything under that URL fails the control and * the refusal is not believed. * * A folder that is NOT inside the uploads directory (a custom folder elsewhere in wp-content, or beside the install) * has its address built from a different pair, and a control in the uploads directory would prove nothing about it. * Writing one next to it is exactly what is not allowed, so such a folder is left UNVERIFIED: the gate then asks for * the deployer's attestation in wp-config.php, or a folder pinned outside the web root, and never calls it private. * * @param string $real_dir The folder's real path. * @param string $canary_base The folder's address, no trailing slash. * @param array $msg wpbr_exposure_messages() for the folder's purpose. * @param string $ext The control file's extension ('' for none). A server may treat a type differently * from `.txt`, so the control is asked for in the same type as the refusal it vouches for. * @return array{verified:bool,reason:string,detail:string} */ function wpbr_exposure_control($real_dir, $canary_base, array $msg, $ext = 'txt') { $unverified = function ($detail) { return array('verified' => false, 'reason' => 'mapping_unverified', 'detail' => $detail); }; $where = wpbr_exposure_control_location(); $uploads = wp_upload_dir(null, false); if ($where === null || !is_array($uploads) || empty($uploads['basedir'])) { return $unverified($msg['control_unwritable']); } // Only a folder addressed through the uploads pair can be vouched for by a file in the uploads directory. $base_url = untrailingslashit((string) $uploads['baseurl']); if (!wpbr_path_is_within($uploads['basedir'], $real_dir) || ($canary_base !== $base_url && strpos($canary_base, $base_url . '/') !== 0)) { return $unverified($msg['control_outside_uploads']); } // The control folder must be a different, public place: not the folder being judged, not inside it, not holding it. $control_dir = $where['dir']; if (wpbr_path_is_within($control_dir, $real_dir) || wpbr_path_is_within($real_dir, $control_dir)) { return $unverified($msg['control_unwritable']); } // THE CONTROL FOLDER MUST BE EXACTLY WHERE IT SAYS, NOT SOMEWHERE A LINK POINTS. Anything able to create a symlink in // the uploads directory could otherwise turn "/" into a door to any folder the web user can write. So // it is never created through, nor used if it is, a link, and its real path must be the real uploads path plus the // slug, before anything is written and again before each write. $real_uploads = realpath($uploads['basedir']); if ($real_uploads === false) { return $unverified($msg['control_unwritable']); } $expected = rtrim(str_replace('\\', '/', $real_uploads), '/') . '/' . basename($control_dir); $place_ok = function () use ($control_dir, $expected) { if (is_link($control_dir) || !is_dir($control_dir)) return false; $real = realpath($control_dir); return $real !== false && rtrim(str_replace('\\', '/', $real), '/') === $expected; }; if (is_link($control_dir) || (file_exists($control_dir) && !is_dir($control_dir))) { return $unverified($msg['control_unwritable']); } $token = wp_generate_password(24, false, false); $name = 'wpbr-control-' . wp_generate_password(12, false, false) . ($ext !== '' ? '.' . $ext : ''); $path = $control_dir . '/' . $name; // A second check can remove the empty folder between this one making it and writing into it (see the cleanup below), // so making and writing are tried together, a few times. Each try re-checks the folder is the real one. $written = false; for ($try = 0; $try < 3 && !$written; $try++) { if (!is_dir($control_dir) && !wp_mkdir_p($control_dir)) { continue; } if (!is_link($control_dir) && !file_exists($control_dir)) { continue; // removed again by another check's cleanup: make it and try again } if (!$place_ok()) { return $unverified($msg['control_unwritable']); } // The folder is the plugin's own, so a sweep here is safe: a control that a request which died left behind goes // once it is a few minutes old. A fresh one belongs to a check running now and is never touched. foreach ((array) glob($control_dir . '/wpbr-control-*') as $stale) { if (is_file($stale) && !is_link($stale) && (time() - (int) filemtime($stale)) > 300) { wp_delete_file($stale); } } $written = !is_link($path) && (bool) @file_put_contents($path, 'wpbr-control:' . $token); } // The folder could still be swapped for a link between the check and the write. Look at where the file really is, and // if it is not inside the real control folder take it away again and believe nothing. $landed = $written ? realpath($path) : false; if ($written && ($landed === false || strpos(str_replace('\\', '/', $landed), $expected . '/') !== 0)) { wp_delete_file($path); $written = false; } if (!$written) { return $unverified($msg['control_unwritable']); } try { $response = wp_remote_get($where['url'] . '/' . $name, array( 'timeout' => 8, 'redirection' => 0, 'headers' => array('Cache-Control' => 'no-cache'), )); } finally { wp_delete_file($path); // The folder itself stays (empty), on purpose: removing it after each check races with another check that has just made it, // and a folder that is deleted and made again by several workers is how a control fails to be placed. Uninstall removes it. } if (is_wp_error($response)) { return $unverified(sprintf($msg['control_error'], $response->get_error_message())); } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 200 && strpos((string) wp_remote_retrieve_body($response), 'wpbr-control:' . $token) !== false) { return array('verified' => true, 'reason' => '', 'detail' => ''); } return $unverified(sprintf($msg['control_failed'], number_format_i18n($code))); } /** * The extension a web server would decide on for this file name: lower-case letters and digits, '' for none. * * @param string $file A file name or path. * @return string */ function wpbr_exposure_ext_of($file) { return strtolower(preg_replace('/[^a-z0-9]/i', '', (string) pathinfo(basename((string) $file), PATHINFO_EXTENSION))); } /** * The file types the exposure probe asks for. * * A fixed list can never be complete: a front server decides what to serve straight from disk by extension, and a * host may serve zip, svg, pptx or something nobody listed. So the list has two parts. The BASE is what the folders * usually hold: the extensions the plugin accepts (a site may widen them with the wpbr_allowed_extensions * filter), the other common document and image types, a name with no extension and one with an extension no server * knows. `txt` stays first: it is the original probe and the cheapest to read. The empty string means "no * extension". The EXTRAS are the types of the files about to be stored: the engine names them when it asks the * gate, so a file is only put where a test file of its OWN type was refused. * * @param string[] $extra Extensions (or file names) of the files about to be stored. * @return string[] Lower-case alphanumeric extensions, no dots, no duplicates, '' for none; at most sixteen base * types and twelve extras. */ function wpbr_exposure_canary_extensions(array $extra = array()) { $allowed = (array) apply_filters('wpbr_allowed_extensions', array('pdf', 'doc', 'docx')); $list = array(); foreach (array_merge(array('txt', 'pdf', 'doc', 'docx', 'xlsx', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bin', ''), $allowed) as $ext) { $ext = strtolower(preg_replace('/[^a-z0-9]/i', '', (string) $ext)); if (!in_array($ext, $list, true)) { $list[] = $ext; } } $list = array_slice($list, 0, 16); $added = 0; foreach ($extra as $ext) { $ext = strtolower(preg_replace('/[^a-z0-9]/i', '', (string) $ext)); if (!in_array($ext, $list, true) && $added < 12) { $list[] = $ext; $added++; } } return $list; } /** * Does a remembered verdict cover every type that is about to be stored? * * A "protected" that was measured without a type proves nothing about that type. */ function wpbr_exposure_verdict_covers(array $verdict, array $extra) { if (!isset($verdict['status']) || $verdict['status'] !== 'protected') return true; // only a pass can be too narrow $probed = isset($verdict['probed']) ? array_map('strval', (array) $verdict['probed']) : array(); foreach (wpbr_exposure_canary_extensions($extra) as $ext) { if (!in_array((string) $ext, $probed, true)) return false; } return true; } /** * Judge what the folder's test files came back with, before any control is asked for. * * Pure: the answers are passed in, so the same rule serves the real probe and the test seam. * * @param array $answers Extension => HTTP status of the folder's test file; 0 means the request * failed or came back with a 200 that did not carry the file; 200 means the * file was read back intact. Evaluated in order. * @param array $msg wpbr_exposure_messages(). * @param array $errors Extension => message for answers of 0 that were request errors. * @return array|null A result fragment (status, reason, detail, exposed_extension) when the answers settle the * question, or null when every type was refused (a control is then needed). */ function wpbr_exposure_judge_answers(array $answers, array $msg, array $errors = array()) { foreach ($answers as $ext => $code) { $code = (int) $code; if ($code === 200) { $detail = $msg['exposed']; if ($ext !== 'txt') { $label = $ext === '' ? __('with no extension', 'wpbr-bulk-replace') : '.' . $ext; /* translators: %s is a description of the test file, such as ".pdf" or "with no extension". */ $detail .= ' ' . sprintf(__('The test file that could be read was %s.', 'wpbr-bulk-replace'), $label); } return array('status' => 'exposed', 'detail' => $detail, 'exposed_extension' => (string) $ext); } if ($code === 0) { $why = isset($errors[$ext]) ? $errors[$ext] : ''; return array( 'status' => 'unknown', 'reason' => 'measurement_failed', 'detail' => $why !== '' ? sprintf($msg['http_error'], $why) : sprintf($msg['unexpected'], number_format_i18n(200)), ); } if (!in_array($code, array(401, 403, 404), true)) { return array('status' => 'unknown', 'reason' => 'measurement_failed', 'detail' => sprintf($msg['unexpected'], number_format_i18n($code))); } } return null; } /** * Judge the controls, once every type was refused. * * @param array $controls Extension => wpbr_exposure_control() result, in the order asked; a * failed one ends the list. * @param int $refused_code The status the folder's files were refused with. * @param array $msg wpbr_exposure_messages(). * @return array Result fragment: 'protected' only when at least one control was asked for and every one verified. */ function wpbr_exposure_judge_controls(array $controls, $refused_code, array $msg) { foreach ($controls as $control) { if (empty($control['verified'])) { return array( 'status' => 'unknown', 'reason' => isset($control['reason']) && $control['reason'] !== '' ? $control['reason'] : 'mapping_unverified', 'control' => 'failed', 'detail' => isset($control['detail']) ? $control['detail'] : '', ); } } if (!$controls) { return array('status' => 'unknown', 'reason' => 'measurement_failed', 'control' => 'failed', 'detail' => sprintf($msg['unexpected'], number_format_i18n(0))); } return array('status' => 'protected', 'control' => 'verified', 'detail' => sprintf($msg['protected'], number_format_i18n((int) $refused_code))); } /** * Is this folder actually unreachable over HTTP? * * .htaccess and web.config are inert on nginx, so the protection files being * present proves nothing. This writes a canary into the folder, requests it, * and remembers the verdict for the folder, the site and the purpose it was * measured for. * * Result: array with `status` ('protected', 'exposed' or 'unknown'), `detail` * (prose), `checked_at`, `which` and `dir`; and, only when the status is * 'unknown', `reason` - a stable value saying why nothing could be concluded: * * unmappable the folder is outside every location WordPress can turn into a URL * unresolvable the folder exists but its real path cannot be read * measurement_failed a URL exists, but the test file could not be written or the request did not * come back with an answer that means yes or no * mapping_unverified the folder's test file was denied, but a second, known-public test file could * not be read at the folder's neighbouring address either (or could not be placed), * so the address used may not reach the folder at all * * WHY THERE IS A SECOND FILE. A refusal (401, 403, 404) is only evidence about the folder if the * address that was asked is the folder's address. A CDN, a bucket URL, a reverse proxy or a rewrite * rule can answer 404 for any path under the URL the probe built, and a 404 for a file that was * never reachable proves nothing. So a refusal is believed only after a CONTROL: a second test file, * with a random token in its body, written into the plugin's OWN public folder in the uploads directory (never beside the * protected folder) and requested at that folder's address with the same request. If the control comes back with its token, the address does reach this part of * the filesystem and the refusal of the folder is real. If it does not - 404, a login page, a * soft-200 page without the token, an error, HTTP auth, no way to place the file - the answer is * 'unknown'. An answer of 200 with the folder's own token ('exposed') needs no control: reading the * file back is proof. * * FAIL-CLOSED: 'protected' is returned only for an HTTP answer of 401, 403 or 404 to a request for a * file the probe itself just wrote, AND a control file in the plugin's own uploads folder that the same server returned * intact. Every other outcome is 'exposed' or 'unknown'. A folder that does not exist yet is * reported protected - there is nothing in it to read - but that is a fact about * this moment, so it is never cached: the folder that appears a minute later is * measured, not assumed. * * @param string $which 'backups' or 'staging'. Anything else is 'unknown'. * @param bool $force Measure now instead of returning a remembered verdict. * @param string|null $dir_override Judge this folder instead of the plugin's own for $which. * @param string[] $files Names of the files about to be stored here. Their types are asked for too, and a remembered * "protected" is reused only if it covered them. * @return array */ function wpbr_check_dir_exposure($which, $force = false, $dir_override = null, array $files = array()) { if (!in_array($which, wpbr_exposure_purposes(), true)) { return array( 'status' => 'unknown', 'reason' => 'measurement_failed', 'which' => (string) $which, 'dir' => '', 'checked_at' => time(), 'detail' => __('That folder is not one whose exposure is measured.', 'wpbr-bulk-replace'), ); } // A folder other than the plugin's own for that purpose - a network job's staging folder, which sits in the // main site's - is judged by its own address and remembered under its own key. $dir = ($dir_override !== null && $dir_override !== '') ? (string) $dir_override : wpbr_exposure_dir($which); $key = wpbr_exposure_cache_key($which, $dir); $extra = array_map('wpbr_exposure_ext_of', $files); $before = wpbr_exposure_cache_read(); if (!$force && isset($before[$key]) && wpbr_exposure_verdict_covers($before[$key], $extra)) { return $before[$key]; } // Types this folder was already measured for stay in the list, so alternating file types do not keep // replacing each other's coverage. if (isset($before[$key]['probed'])) { $extra = array_merge($extra, array_map('strval', (array) $before[$key]['probed'])); } $msg = wpbr_exposure_messages($which); $result = array('status' => 'unknown', 'detail' => '', 'checked_at' => time(), 'which' => $which, 'dir' => (string) $dir); $remember = function ($result) use ($key) { $verdicts = wpbr_exposure_cache_read(); $verdicts[$key] = $result; if (count($verdicts) > 12) { // two purposes and a few folders each; a bound, not a feature uasort($verdicts, function ($a, $b) { return (int) $b['checked_at'] - (int) $a['checked_at']; }); $verdicts = array_slice($verdicts, 0, 12, true); } set_transient('wpbr_exposure_check', array('v' => 3, 'verdicts' => $verdicts), DAY_IN_SECONDS); return $result; }; if (!is_dir($dir)) { $result['status'] = 'protected'; $result['detail'] = $msg['missing']; return $result; // not remembered: true now, and the folder may exist a moment from now } $real_dir = rtrim(str_replace('\\', '/', (string) realpath($dir)), '/'); if ($real_dir === '') { $result['reason'] = 'unresolvable'; $result['detail'] = $msg['unresolved']; return $remember($result); } $canary_base = wpbr_dir_public_url_base($dir); // No trustworthy URL means no measurement, and no measurement is not a // pass. The folder may well be unreachable - that is the whole point of // pinning it somewhere else - but the plugin has not established it, and // saying "protected" here would read as a verified result. if ($canary_base === '') { $result['reason'] = 'unmappable'; $result['detail'] = $msg['unmappable']; return $remember($result); } // ONE CANARY PER FILE TYPE THE FOLDER WILL HOLD, NOT ONE .txt. A web server decides what to do with a request // partly by its extension: a host that fronts Apache with nginx often serves documents and images straight from // disk and never consults the .htaccess that denies the folder, while a .txt is passed on and refused. A // measurement that asks only for text then reports the folder protected while its PDFs can be fetched by anybody. $token = wp_generate_password(12, false, false); $canaries = array(); // keyed by extension; each value is a path and a url foreach (wpbr_exposure_canary_extensions($extra) as $ext) { $ext = (string) $ext; $name = 'wpbr-canary-' . $token . ($ext !== '' ? '.' . $ext : ''); $path = $real_dir . '/' . $name; if (!@file_put_contents($path, 'wpbr-canary')) { foreach ($canaries as $made) wp_delete_file($made[0]); if (is_file($path)) wp_delete_file($path); $result['reason'] = 'measurement_failed'; $result['detail'] = $msg['unwritable']; return $remember($result); } $canaries[$ext] = array($path, $canary_base . '/' . $name); } $answers = array(); $errors = array(); try { foreach ($canaries as $ext => $c) { $response = wp_remote_get($c[1], array( 'timeout' => 8, 'redirection' => 0, 'headers' => array('Cache-Control' => 'no-cache'), )); if (is_wp_error($response)) { // Blocked loopback, HTTP auth, firewall or a TLS problem. Not a pass. $answers[$ext] = 0; $errors[$ext] = $response->get_error_message(); break; } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 200 && strpos((string) wp_remote_retrieve_body($response), 'wpbr-canary') === false) { $code = 0; // a soft 200 (a login page, an error page): neither a read of the file nor a refusal } $answers[$ext] = $code; // One readable type, or one answer that means nothing, is the answer; there is no need to ask for the rest. if ($code === 200 || !in_array($code, array(401, 403, 404), true)) { break; } } } finally { foreach ($canaries as $c) wp_delete_file($c[0]); // whatever the requests did, the test files do not stay } $result['probed'] = array_map('strval', array_keys($answers)); $judged = wpbr_exposure_judge_answers($answers, $msg, $errors); if ($judged !== null) { return $remember(array_merge($result, $judged)); } // Every type was refused. Believe it only if the address provably reaches this part of the filesystem, and // for each type separately: a server that routes documents differently from text can refuse one and not reach // the other's location at all. $controls = array(); foreach (array_keys($answers) as $ext) { $controls[$ext] = wpbr_exposure_control($real_dir, $canary_base, $msg, (string) $ext); if (!$controls[$ext]['verified']) break; } return $remember(array_merge($result, wpbr_exposure_judge_controls($controls, (int) reset($answers), $msg))); } /** * The backups folder's verdict. Kept as it was named and called; see * wpbr_check_dir_exposure(). */ function wpbr_check_backups_exposure($force = false) { return wpbr_check_dir_exposure('backups', $force); } /** * Both folders' verdicts, keyed by purpose. * * @return array */ function wpbr_check_exposure_all($force = false) { $out = array(); foreach (wpbr_exposure_purposes() as $which) { $out[$which] = wpbr_check_dir_exposure($which, $force); } return $out; } /** * Are these two files byte-for-byte the same? * * Used to tell "this replacement has already happened" from "this replacement * is about to happen", which is the difference between keeping a backup of the * original and storing a copy of the replacement under its name. * * Size is compared first because it settles almost every case without reading * either file, and the hash is only reached for two files that are already the * same length. * * @param string $a * @param string $b * @return bool False if either is unreadable - an unknown answer is not a match. */ function wpbr_files_are_identical($a, $b) { if (!$a || !$b || !is_readable($a) || !is_readable($b)) return false; $size_a = filesize($a); $size_b = filesize($b); if ($size_a === false || $size_b === false || $size_a !== $size_b) return false; return md5_file($a) === md5_file($b); }