, 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 );
}
}