get_contents( $path );
if ( false === $content ) {
return true;
}
// The beginning and the end. Code hides in both of those places.
$head = substr( $content, 0, 4096 ) . substr( $content, -4096 );
$needles = array( '\n"
. "\tRemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar .pl .py .jsp .asp .sh .cgi\n"
. "\tRemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar .pl .py .jsp .asp .sh .cgi\n"
. "\n"
. "\n"
. "\tSetHandler default-handler\n"
. "\n"
. "\n"
. "\t\n\t\tRequire all denied\n\t\n"
. "\t\n\t\tDeny from all\n\t\n"
. "\n";
self::write( $htaccess, $rules );
}
// The same for servers running Windows.
$webconfig = trailingslashit( $dir ) . 'web.config';
if ( ! file_exists( $webconfig ) ) {
self::write(
$webconfig,
"\n\n\t\n"
. "\t\t\n\t\t\t\n\t\t\n"
. "\t\n\n"
);
}
// An empty index, so the folder is not listed even if the server does serve it.
$index = trailingslashit( $dir ) . 'index.php';
if ( ! file_exists( $index ) ) {
self::write( $index, "put_contents( $path, $content, FS_CHMOD_FILE );
}
}
/**
* The month the file being accepted right now goes into.
*
* @var string
*/
private static $month = '';
/**
* The extension of the file being accepted right now.
*
* @var string
*/
private static $ext = '';
/**
* Tells WordPress the file goes into OUR folder, not into the normal media
* library.
*
* @param array $dirs The folder data.
*
* @return array
*/
public static function filter_dir( $dirs ) {
$ours = self::DIR . '/' . self::$month;
$dirs['subdir'] = '/' . $ours;
$dirs['path'] = trailingslashit( $dirs['basedir'] ) . $ours;
$dirs['url'] = trailingslashit( $dirs['baseurl'] ) . $ours;
return $dirs;
}
/**
* The name the file goes onto the disk under: 32 random characters.
*
* The visitor's original name NEVER reaches the disk, it is kept in the
* database next to the entry. That way neither a double extension nor odd
* characters can be smuggled through.
*
* @param string $dir The folder.
* @param string $name The name WordPress suggested.
* @param string $ext The extension.
*
* @return string
*/
public static function random_name( $dir, $name, $ext ) {
unset( $dir, $name, $ext );
return wp_generate_password( 32, false, false ) . '.' . self::$ext;
}
/**
* Accepts one file.
*
* @param array $file One row out of `$_FILES`.
* @param array $allowed The allowed types, without the dot.
* @param int $max The largest size in bytes.
*
* @return array|WP_Error The data of the saved file, or an error.
*/
public static function accept( $file, $allowed, $max ) {
// 1. Whether the transfer succeeded at all.
if ( ! isset( $file['error'] ) || UPLOAD_ERR_OK !== (int) $file['error'] ) {
return new WP_Error( 'upload', self::error_text( isset( $file['error'] ) ? (int) $file['error'] : -1 ) );
}
if ( empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
return new WP_Error( 'upload', __( 'That file could not be read. Please try again.', '2ranq-forms' ) );
}
// 2. The size. An empty file is refused just like an oversized one.
$size = isset( $file['size'] ) ? (int) $file['size'] : 0;
if ( $size <= 0 ) {
return new WP_Error( 'upload', __( 'That file is empty.', '2ranq-forms' ) );
}
if ( $size > $max ) {
return new WP_Error(
'upload',
sprintf(
/* translators: %s: largest allowed size, for example 5 MB. */
__( 'That file is too large. The limit is %s.', '2ranq-forms' ),
size_format( $max )
)
);
}
// 3. The file type, by CONTENT and not by the extension in the name.
$name = isset( $file['name'] ) ? sanitize_file_name( wp_basename( $file['name'] ) ) : '';
$check = wp_check_filetype_and_ext( $file['tmp_name'], $name );
$ext = $check['ext'] ? strtolower( $check['ext'] ) : '';
$named = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
if ( ! $ext ) {
// WordPress did not recognise the content. That is either plain text, which
// cannot be recognised, or a file that is MISREPRESENTING itself.
//
// There was a hole here: the earlier code trusted the file name in that
// case, so `attack.php.png` passed as an image. Now the name is accepted
// ONLY for a few plain text types, and only after the content is looked at.
if ( ! in_array( $named, self::text_types(), true ) ) {
return new WP_Error( 'upload', __( 'That file does not match the kind of file it claims to be.', '2ranq-forms' ) );
}
$ext = $named;
}
if ( ! $ext ) {
return new WP_Error( 'upload', __( 'That kind of file is not allowed.', '2ranq-forms' ) );
}
// 3a. The name and the content have to say the same thing. `attack.php.png`
// has a name that says `png` and content that says something else. Out.
if ( $named && $named !== $ext ) {
return new WP_Error( 'upload', __( 'That file does not match the kind of file it claims to be.', '2ranq-forms' ) );
}
// 3b. An image has to be a real image, not a file with an image extension.
if ( in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp' ), true ) ) {
$info = @getimagesize( $file['tmp_name'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! $info || empty( $info[0] ) || empty( $info[1] ) ) {
return new WP_Error( 'upload', __( 'That file is not a real image.', '2ranq-forms' ) );
}
}
// 3c. The last look: code in the content. Done ONLY on files that really are
// text. On compressed data, which is what an image and a document are,
// such a look fires falsely: measured over 1500 real files, the two bytes
// `<%` refused about 8.5 percent of VALID images. The image is already
// proven by the `getimagesize` call above.
if ( in_array( $ext, self::text_types(), true ) && self::looks_like_code( $file['tmp_name'] ) ) {
return new WP_Error( 'upload', __( 'That file contains code and cannot be accepted.', '2ranq-forms' ) );
}
// 4. The last barrier, whatever the form settings say.
if ( in_array( $ext, self::forbidden(), true ) ) {
return new WP_Error( 'upload', __( 'That kind of file is not allowed.', '2ranq-forms' ) );
}
// 5. A list of ALLOWED types, not a list of forbidden ones.
if ( ! in_array( $ext, $allowed, true ) ) {
return new WP_Error(
'upload',
sprintf(
/* translators: %s: list of allowed file types. */
__( 'Only these kinds of file are allowed: %s', '2ranq-forms' ),
implode( ', ', $allowed )
)
);
}
// 6. A double extension. `image.php.jpg` passes the check above, but the name
// still carries the dangerous part, so the name is made random regardless.
$dir = self::dir();
if ( ! $dir ) {
return new WP_Error( 'upload', __( 'The file could not be saved on the server.', '2ranq-forms' ) );
}
// 7. The move is done by WordPress itself, through `wp_handle_upload`. That
// way neither `move_uploaded_file` nor `chmod` is touched, both of which
// the directory forbids, and what WordPress checks on the way comes free.
//
// The two hooks below say where the file goes and what it is called. They
// are attached for this one call only, and taken off right away.
self::$month = gmdate( 'Y-m' );
self::$ext = $ext;
require_once ABSPATH . 'wp-admin/includes/file.php';
add_filter( 'upload_dir', array( __CLASS__, 'filter_dir' ) );
$moved = wp_handle_upload(
$file,
array(
'test_form' => false,
'unique_filename_callback' => array( __CLASS__, 'random_name' ),
)
);
remove_filter( 'upload_dir', array( __CLASS__, 'filter_dir' ) );
if ( ! is_array( $moved ) || empty( $moved['file'] ) || isset( $moved['error'] ) ) {
return new WP_Error( 'upload', __( 'The file could not be saved on the server.', '2ranq-forms' ) );
}
return array(
'name' => $name,
'file' => self::$month . '/' . wp_basename( $moved['file'] ),
'size' => $size,
'type' => $ext,
);
}
/**
* Turns an error number into a sentence a person understands.
*
* @param int $code The error number out of `$_FILES`.
*
* @return string
*/
private static function error_text( $code ) {
switch ( $code ) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return sprintf(
/* translators: %s: largest size the server allows. */
__( 'That file is too large. The server allows up to %s.', '2ranq-forms' ),
size_format( self::server_limit() )
);
case UPLOAD_ERR_PARTIAL:
return __( 'The file only arrived halfway. Please try again.', '2ranq-forms' );
case UPLOAD_ERR_NO_FILE:
return __( 'No file was chosen.', '2ranq-forms' );
default:
return __( 'The file could not be uploaded. Please try again.', '2ranq-forms' );
}
}
}