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