, protected by deny files and an unguessable name. * * Never outside the WordPress installation: such a path is not guaranteed to exist, to be writable * or to persist, and a plugin has no business writing there. Not in uploads either, which is served * to the public by design. wp-content is where a backup plugin's own storage belongs, and the * canary check below tells the administrator if this server serves it anyway. * * No .php file is ever written here, and the folder name is fixed: `accessnow-replica-` is how * Restore\Files::unit() and Files\Exclusions recognise this folder as the plugin's own and leave it * alone. Rename that prefix and a restore will move the backups aside and delete them. * * Nothing in here may depend on the WordPress database surviving: restores swap it. * * @package AccessNow\Replica */ namespace AccessNow\Replica; use AccessNow\Replica\Util\Paths; defined( 'ABSPATH' ) || exit; // Exception messages are plain text: they are escaped where shown (esc_html() in PHP, textContent in admin.js). // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped /** * Paths and safe file helpers inside the storage directory. */ final class Storage { const PATH_OPTION = 'accessnow_replica_storage_path'; const TOKEN_OPTION = 'accessnow_replica_storage_token'; /** * The first line versions before 1.0.3 put in front of guarded JSON (15 bytes), by checksum: * it is only recognised and removed now, never written. */ const OLD_GUARD_MD5 = 'ce994b18a71efcf160df25b1bf4d457b'; /** * Cached absolute path with trailing slash. * * @var string|null */ private static $dir = null; /** * Storage root with trailing slash. Creates and protects it on first use. * * @throws \RuntimeException When no writable location exists. */ public static function dir() { if ( null !== self::$dir ) { return self::$dir; } $candidates = array(); if ( defined( 'ACCESSNOW_REPLICA_STORAGE_DIR' ) && ACCESSNOW_REPLICA_STORAGE_DIR ) { $candidates[] = (string) ACCESSNOW_REPLICA_STORAGE_DIR; } else { $saved = (string) get_option( self::PATH_OPTION, '' ); if ( '' !== $saved ) { $candidates[] = $saved; } // Inside wp-content, never above the install: a plugin must not write outside the // WordPress installation, and a folder there is not guaranteed to exist, be writable // or survive. wp-content rather than uploads because uploads is served to the public // by design, and a backup is the last thing that should be. $candidates[] = Paths::content_dir() . '/accessnow-replica-' . self::token(); } foreach ( $candidates as $candidate ) { $candidate = trailingslashit( wp_normalize_path( $candidate ) ); if ( self::prepare( $candidate ) ) { self::$dir = $candidate; if ( ! defined( 'ACCESSNOW_REPLICA_STORAGE_DIR' ) && get_option( self::PATH_OPTION ) !== $candidate ) { update_option( self::PATH_OPTION, $candidate, false ); } return self::$dir; } } throw new \RuntimeException( 'Site Replica could not create a private storage folder in wp-content. Make wp-content writable, or set ACCESSNOW_REPLICA_STORAGE_DIR in wp-config.php to a folder the web server does not serve.' ); } /** * Whether the storage folder has been made, without making it. * * Pages that only show things ask this, so opening Site Replica writes nothing. The folder is * made the first time something has to be written there: a backup, a key, a log line. */ public static function exists() { if ( null !== self::$dir ) { return true; } if ( defined( 'ACCESSNOW_REPLICA_STORAGE_DIR' ) && ACCESSNOW_REPLICA_STORAGE_DIR ) { return is_dir( (string) ACCESSNOW_REPLICA_STORAGE_DIR ); } $saved = (string) get_option( self::PATH_OPTION, '' ); return '' !== $saved && is_dir( $saved ); } /** * Random token used in default storage folder names. */ private static function token() { $token = (string) get_option( self::TOKEN_OPTION, '' ); if ( ! preg_match( '/^[a-z0-9]{16}$/', $token ) ) { $token = strtolower( wp_generate_password( 16, false, false ) ); update_option( self::TOKEN_OPTION, $token, false ); } return $token; } /** * Whether files in the storage folder can be downloaded from the web (for example on servers * that ignore .htaccess). Checked with a random file and a request to this site; cached. * * @return bool|null True when exposed, false when not (or outside the website), null when unknown. */ public static function web_exposed() { try { $dir = self::dir(); } catch ( \RuntimeException $e ) { return null; } if ( ! self::is_web_reachable_path( $dir ) ) { return false; } $cached = get_transient( 'accessnow_replica_storage_exposed' ); if ( in_array( $cached, array( 'yes', 'no', 'unknown' ), true ) ) { return 'yes' === $cached ? true : ( 'no' === $cached ? false : null ); } $content = trailingslashit( Paths::content_dir() ); $root = trailingslashit( wp_normalize_path( ABSPATH ) ); if ( 0 === strpos( $dir, $content ) ) { $url = content_url( substr( $dir, strlen( $content ) ) ); } elseif ( 0 === strpos( $dir, $root ) ) { $url = site_url( substr( $dir, strlen( $root ) ) ); } else { set_transient( 'accessnow_replica_storage_exposed', 'unknown', HOUR_IN_SECONDS ); return null; } $name = 'tmp/canary-' . strtolower( wp_generate_password( 12, false, false ) ) . '.txt'; $token = wp_generate_password( 32, false, false ); file_put_contents( $dir . $name, $token ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Private storage. $response = wp_remote_get( trailingslashit( $url ) . $name, array( 'timeout' => 8, 'redirection' => 0, // This site asking itself; local and self-signed certificates are common there. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter. ) ); @unlink( $dir . $name ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink if ( is_wp_error( $response ) ) { set_transient( 'accessnow_replica_storage_exposed', 'unknown', HOUR_IN_SECONDS ); return null; } $exposed = 200 === (int) wp_remote_retrieve_response_code( $response ) && false !== strpos( (string) wp_remote_retrieve_body( $response ), $token ); // An hour, not half a day: a host can change how it serves this folder at any time, and // "safe" is the answer it would be worst to keep believing after it stopped being true. set_transient( 'accessnow_replica_storage_exposed', $exposed ? 'yes' : 'no', HOUR_IN_SECONDS ); return $exposed; } /** * Whether a path sits under the web server's document root. * * @param string $path Absolute path. */ private static function is_web_reachable_path( $path ) { $docroot = isset( $_SERVER['DOCUMENT_ROOT'] ) ? wp_normalize_path( (string) realpath( sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) ) ) : ''; if ( '' === $docroot ) { $docroot = wp_normalize_path( untrailingslashit( ABSPATH ) ); } $path = wp_normalize_path( $path ); return 0 === strpos( trailingslashit( $path ), trailingslashit( $docroot ) ); } /** * Removes the index.php an older version wrote here, and only that exact file. * * Anything else with that name belongs to somebody else and is left alone. * * @param string $dir Absolute path with trailing slash. */ private static function retire_php_index( $dir ) { $path = $dir . 'index.php'; if ( ! @is_file( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return; } // Matched by checksum, so this file never contains guard-file text itself. if ( '67442c5615eba73d105c0715c6620850' === @md5_file( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink } } /** * Whether a folder could be created here: the nearest existing ancestor must be writable. * * @param string $dir Absolute path with trailing slash. */ private static function can_create( $dir ) { $path = untrailingslashit( wp_normalize_path( $dir ) ); for ( $guard = 0; $guard < 60; $guard++ ) { $parent = dirname( $path ); if ( '' === $parent || $parent === $path ) { return false; } if ( @is_dir( $parent ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return (bool) @is_writable( $parent ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_is_writable } $path = $parent; } return false; } /** * Creates a candidate folder with guard files. Returns false when unusable. * * Nothing in here may emit: every candidate is a guess, and a guess that fails is ordinary. * The plugin is activated on hosts where the first guess is a read-only filesystem. * * @param string $dir Absolute path with trailing slash. */ private static function prepare( $dir ) { // Look before leaping. @ hides a warning from PHP's own output but not from a custom error // handler, which is what debugging tools install — so the point is not to provoke one: // don't call mkdir() where it cannot succeed. if ( ! @is_dir( $dir ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( ! self::can_create( $dir ) || ! @wp_mkdir_p( $dir ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return false; } } if ( ! @is_writable( $dir ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_is_writable return false; } @chmod( $dir, 0700 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_chmod // No .php file is ever written here. An empty index.html stops a directory listing just as // well, and this plugin has no business putting code on anybody's disk. self::retire_php_index( $dir ); $guards = array( 'index.html' => "\n", '.htaccess' => "Require all denied\n\nDeny from all\n\n", 'web.config' => "\n\n", ); foreach ( $guards as $name => $content ) { if ( ! file_exists( $dir . $name ) && false === @file_put_contents( $dir . $name, $content ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Private storage; a candidate that cannot be written is simply unusable. return false; } } foreach ( array( 'jobs', 'sets', 'source', 'logs', 'tmp' ) as $sub ) { // A half-made folder is not usable: say so now rather than failing later, mid-backup, // with a message about something else. if ( ! @is_dir( $dir . $sub ) && ! @wp_mkdir_p( $dir . $sub ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return false; } } return true; } /** * Absolute path inside storage. * * @param string $relative Relative path. */ public static function path( $relative = '' ) { return self::dir() . ltrim( $relative, '/' ); } /** * Writes a file atomically (temp file + rename). * * @param string $path Absolute path. * @param string $content Bytes. * @throws \RuntimeException On failure. */ public static function write_atomic( $path, $content ) { $dir = dirname( $path ); if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $tmp = $path . '.tmp-' . getmypid() . '-' . wp_rand( 1000, 9999 ); if ( false === file_put_contents( $tmp, $content, LOCK_EX ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents throw new \RuntimeException( 'Could not write ' . basename( $path ) . '. Is the disk full?' ); } if ( ! rename( $tmp, $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink throw new \RuntimeException( 'Could not replace ' . basename( $path ) . '.' ); } } /** * Writes JSON atomically. Guarded files start with a PHP exit so they print nothing if served. * * @param string $path Absolute path. * @param mixed $data Data. * @param bool $guarded Prefix with the PHP guard. */ public static function write_json( $path, $data, $guarded = false ) { $json = wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); if ( false === $json ) { throw new \RuntimeException( 'Could not encode ' . basename( $path ) . ' as JSON.' ); } // Plain JSON always: no PHP is written into storage. The folder refuses web requests // (index.html, .htaccess, web.config, and web_exposed() checks it); files that older versions // wrote with a guard line are still read. $guarded is kept for callers and ignored. unset( $guarded ); self::write_atomic( $path, $json . "\n" ); } /** * Reads JSON written by write_json(). Returns null when missing or invalid. * * @param string $path Absolute path. * @return mixed */ public static function read_json( $path ) { if ( ! is_readable( $path ) ) { return null; } $raw = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( self::OLD_GUARD_MD5 === md5( substr( $raw, 0, 15 ) ) ) { $raw = substr( $raw, 15 ); } $data = json_decode( $raw, true ); return JSON_ERROR_NONE === json_last_error() ? $data : null; } /** * Free bytes on the storage volume, or null when unknown. */ public static function free_bytes() { if ( ! function_exists( 'disk_free_space' ) ) { return null; } $free = @disk_free_space( self::dir() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return false === $free ? null : (float) $free; } /** * Recursively deletes a directory inside storage (refuses anything outside it). * * @param string $dir Absolute path. */ public static function rrmdir( $dir ) { $dir = wp_normalize_path( $dir ); $root = self::dir(); if ( 0 !== strpos( trailingslashit( $dir ), $root ) || trailingslashit( $dir ) === $root ) { return; } Util\Fs::rrmdir( $dir ); } }