详解 WordPress `wp_handle_upload()` 函数的源码:如何处理文件上传,并进行安全验证。

各位同学们,早上好!我是你们今天的WordPress文件上传“解剖师”——老码农。今天咱们就来扒一扒WordPress的wp_handle_upload()这个函数,看看它到底是怎么处理文件上传的,又是怎么把那些“坏家伙”(恶意文件)挡在门外的。

开场白:文件上传的“那些事儿”

文件上传,听起来简单,不就是把文件从浏览器“嗖”的一声扔到服务器上嘛。但实际上,这里面的门道可不少。你想啊,如果没有安全措施,随便什么人都能上传文件,那服务器岂不是成了“垃圾场”?更可怕的是,如果有人上传了恶意脚本,那整个网站都可能被黑掉。

所以,WordPress在文件上传这块,可是下了不少功夫的。wp_handle_upload()就是负责处理文件上传的核心函数之一。

正文:深入wp_handle_upload()的源码

咱们直接上代码,然后逐行分析,保证让你们看得明明白白。

<?php

/**
 * Handles upload of a file.
 *
 * @since 2.0.0
 *
 * @param array  $file              An array of data for a single file.
 *                                  @type string 'name'     The name of the file.
 *                                  @type string 'type'     The type of the file.
 *                                  @type string 'tmp_name' The temporary location of the file.
 *                                  @type int    'error'    The error code for the file upload.
 *                                  @type int    'size'     The size of the file in bytes.
 * @param array  $overrides         Optional. Array of key => value arguments to change upload behavior.
 *                                  Default empty array. Possible arguments:
 *                                    * 'test_form' - Whether to test the form ($_POST) or not.
 *                                      Defaults to true. A false value can be passed to bypass the
 *                                      'action' and 'test' form input fields.
 *                                    * 'unique_filename_callback' - A callback that takes the directory, filename,
 *                                      and extension as arguments, and returns a unique filename.
 *                                      Defaults to 'wp_unique_filename'.
 * @return array On success, returns an array containing:
 *               * 'file' - The path to the uploaded file relative to the uploads directory.
 *               * 'url' - The URL to the uploaded file.
 *               * 'type' - The mime type of the file.
 *               * 'error' - False on success.
 *
 *               On failure, returns an array containing:
 *               * 'error' - An error message.
 */
function wp_handle_upload( $file, $overrides = array() ) {
    // These files may not be included since PHP may not have file uploads enabled.
    if ( ! function_exists( 'wp_get_current_user' ) ) {
        require_once ABSPATH . WPINC . '/pluggable.php';
    }

    $defaults = array( 'test_form' => true, 'test_size' => true, 'test_upload' => true, 'unique_filename_callback' => 'wp_unique_filename' );
    $overrides = wp_parse_args( $overrides, $defaults );

    /**
     * Filters the upload directory for the current site.
     *
     * @since 2.0.0
     *
     * @param array $upload {
     *     Array of upload directory data.
     *
     *     @type string 'path'    The path to the upload directory.
     *     @type string 'url'     The URL to the upload directory.
     *     @type string 'subdir'  The path to the upload directory relative to the base upload directory.
     *     @type bool   'basedir' Path to the base upload directory.
     *     @type string 'baseurl' URL to the base upload directory.
     *     @type string 'error'   False on success, otherwise contains the error message.
     * }
     */
    $upload = apply_filters( 'wp_upload_dir', wp_upload_dir() );

    if ( ! empty( $upload['error'] ) ) {
        return array( 'error' => $upload['error'] );
    }

    $tmp_name = $file['tmp_name'];
    if ( ! is_uploaded_file( $tmp_name ) ) {
        return array( 'error' => __( 'Specified file failed upload test.' ) );
    }

    // Check filesize.
    if ( $overrides['test_size'] && ! ( ( $file['size'] > 0 ) && ( $file['size'] < wp_max_upload_size() ) ) ) {
        return array( 'error' => sprintf( __( 'The uploaded file exceeds the maximum allowed size for your site.' ), size_format( wp_max_upload_size() ) ) );
    }

    $wp_filetype = wp_check_filetype( $file['name'], null );

    extract( apply_filters( 'upload_mimes', array( 'ext' => false, 'type' => false ) ) );

    // Check file extension.
    if ( ( ! empty( $ext ) && ! empty( $type ) ) && ( ! isset( $mimes[ $ext ] ) || strpos( $mimes[ $ext ], $type ) === false ) ) {
        return array( 'error' => __( 'Sorry, this file type is not permitted for security reasons.' ) );
    }

    // Check for form submission.
    if ( $overrides['test_form'] ) {
        check_admin_referer( 'media-form' );
    }

    // A properly uploaded file will pass this test.
    // It makes sure that it was uploaded via HTTP POST.
    if ( $overrides['test_upload'] && ! @is_uploaded_file( $tmp_name ) ) { // phpcs:ignore WordPress.PHP.NoSilencing.MaybeSilent
        return array( 'error' => __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.' ) );
    }

    // Move the file to the uploads directory.
    $filename = wp_unique_filename( $upload['path'], $file['name'], $unique_filename_callback );

    // Move the file to the uploads directory.
    $new_file = $upload['path'] . "/$filename";

    if ( false === @move_uploaded_file( $tmp_name, $new_file ) ) { // phpcs:ignore WordPress.PHP.NoSilencing.MaybeSilent
        return array( 'error' => sprintf( __( 'Could not move the file to: %s' ), $upload['path'] ) );
    }

    // Set correct file permissions.
    $stat = stat( dirname( $new_file ) );
    $perms = $stat['mode'] & 0007777;
    $perms = $perms & 0000777 | 0000110;
    if ( ! chmod( $new_file, $perms ) ) {
        $message = sprintf( __( 'Failed to set file permissions: %s' ), substr( sprintf( '%o', $perms ), -3 ) );
        /**
         * Fires after an uploaded file's permissions are set.
         *
         * @since 3.9.0
         *
         * @param string $new_file Path to the newly uploaded file.
         * @param int    $perms    Permissions of the new file.
         * @param int    $error    Error code.
         */
        do_action( 'wp_handle_upload_permissions', $new_file, $perms, 1 );
        error_log( $message );
    }

    // Compute the URL.
    $url = $upload['url'] . "/$filename";

    return array( 'file' => $new_file, 'url' => $url, 'type' => $wp_filetype['type'] );
}

第一步:准备工作(函数开头)

    if ( ! function_exists( 'wp_get_current_user' ) ) {
        require_once ABSPATH . WPINC . '/pluggable.php';
    }

    $defaults = array( 'test_form' => true, 'test_size' => true, 'test_upload' => true, 'unique_filename_callback' => 'wp_unique_filename' );
    $overrides = wp_parse_args( $overrides, $defaults );
  • 引入pluggable.php: 首先,它会检查wp_get_current_user()这个函数是否存在。如果不存在,就引入pluggable.phppluggable.php 里面包含了许多WordPress的核心函数,像用户验证、Cookie处理等等。 即使没有用户验证,也要引入,为后续操作做准备。

  • 设置默认参数: $defaults 数组定义了一些默认的配置项,例如:

    • test_form: 是否检查表单提交(防止CSRF攻击)。
    • test_size: 是否检查文件大小。
    • test_upload: 是否检查文件是否是通过HTTP POST上传的。
    • unique_filename_callback: 用于生成唯一文件名的回调函数。
  • 合并参数: wp_parse_args() 函数将用户传入的 $overrides 参数和 $defaults 参数合并,最终得到一个完整的配置数组。

第二步:获取上传目录(wp_upload_dir()

    $upload = apply_filters( 'wp_upload_dir', wp_upload_dir() );

    if ( ! empty( $upload['error'] ) ) {
        return array( 'error' => $upload['error'] );
    }
  • 获取上传目录信息: wp_upload_dir() 函数会返回一个数组,包含上传目录的路径、URL等信息。

    键名 含义
    path 上传目录的完整路径(服务器上的绝对路径)
    url 上传目录的URL
    subdir 子目录(相对于basedir的路径)
    basedir 基础上传目录的完整路径
    baseurl 基础上传目录的URL
    error 如果有错误,则包含错误信息
  • apply_filters('wp_upload_dir', ...): 这是一个过滤器钩子,允许开发者修改上传目录的信息。比如说,你可以通过这个钩子将文件上传到云存储服务,而不是本地服务器。

  • 检查错误: 如果wp_upload_dir()返回的数组中包含error键,说明获取上传目录失败,直接返回错误信息。

第三步:初步验证(文件是否上传成功)

    $tmp_name = $file['tmp_name'];
    if ( ! is_uploaded_file( $tmp_name ) ) {
        return array( 'error' => __( 'Specified file failed upload test.' ) );
    }
  • 获取临时文件名: $file['tmp_name'] 包含了上传文件的临时文件名(服务器上的临时存储路径)。
  • is_uploaded_file(): 这个函数用于检查文件是否是通过HTTP POST上传的。 这是一个非常重要的安全检查,可以防止恶意用户伪造上传请求。

第四步:文件大小验证(防止上传过大文件)

    if ( $overrides['test_size'] && ! ( ( $file['size'] > 0 ) && ( $file['size'] < wp_max_upload_size() ) ) ) {
        return array( 'error' => sprintf( __( 'The uploaded file exceeds the maximum allowed size for your site.' ), size_format( wp_max_upload_size() ) ) );
    }
  • wp_max_upload_size(): 这个函数返回允许上传的最大文件大小(以字节为单位)。 这个值可以在WordPress后台设置,也可以通过php.ini文件配置。
  • 检查文件大小: 如果文件大小超过了允许的最大值,就返回错误信息。

第五步:文件类型验证(wp_check_filetype()upload_mimes过滤器)

    $wp_filetype = wp_check_filetype( $file['name'], null );

    extract( apply_filters( 'upload_mimes', array( 'ext' => false, 'type' => false ) ) );

    // Check file extension.
    if ( ( ! empty( $ext ) && ! empty( $type ) ) && ( ! isset( $mimes[ $ext ] ) || strpos( $mimes[ $ext ], $type ) === false ) ) {
        return array( 'error' => __( 'Sorry, this file type is not permitted for security reasons.' ) );
    }
  • wp_check_filetype(): 这个函数用于检查文件的类型。它会根据文件的扩展名和MIME类型来判断文件类型。
  • apply_filters('upload_mimes', ...): 这是一个过滤器钩子,允许开发者修改允许上传的文件类型。 $mimes 数组定义了允许上传的文件类型和对应的MIME类型。
  • 检查文件扩展名和MIME类型: 如果文件的扩展名和MIME类型不在允许的列表中,就返回错误信息。

第六步:表单验证(防止CSRF攻击)

    if ( $overrides['test_form'] ) {
        check_admin_referer( 'media-form' );
    }
  • check_admin_referer(): 这个函数用于验证表单提交的nonce值。 Nonce是一种安全令牌,用于防止跨站请求伪造(CSRF)攻击。 'media-form' 是一个action,WordPress会生成一个对应的nonce值,并在表单中包含这个nonce值。 check_admin_referer() 会验证表单提交的nonce值是否与生成的nonce值匹配。

第七步:再次确认文件是否上传成功

    if ( $overrides['test_upload'] && ! @is_uploaded_file( $tmp_name ) ) { // phpcs:ignore WordPress.PHP.NoSilencing.MaybeSilent
        return array( 'error' => __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.' ) );
    }

为了确保万无一失,这里再次使用is_uploaded_file()来验证文件是否确实是通过HTTP POST上传的。

第八步:生成唯一文件名(wp_unique_filename()

    $filename = wp_unique_filename( $upload['path'], $file['name'], $overrides['unique_filename_callback'] );
  • wp_unique_filename(): 这个函数用于生成一个唯一的文件名。 它会检查上传目录中是否存在同名的文件,如果存在,则在文件名后面添加一个数字,直到找到一个唯一的文件名。
  • $overrides['unique_filename_callback']: 允许自定义生成唯一文件名的函数。

第九步:移动文件(move_uploaded_file()

    $new_file = $upload['path'] . "/$filename";

    if ( false === @move_uploaded_file( $tmp_name, $new_file ) ) { // phpcs:ignore WordPress.PHP.NoSilencing.MaybeSilent
        return array( 'error' => sprintf( __( 'Could not move the file to: %s' ), $upload['path'] ) );
    }
  • move_uploaded_file(): 这个函数用于将上传的临时文件移动到指定的目录。 这是一个非常重要的步骤,因为它会将文件从临时存储位置移动到永久存储位置。

第十步:设置文件权限(chmod()

    $stat = stat( dirname( $new_file ) );
    $perms = $stat['mode'] & 0007777;
    $perms = $perms & 0000777 | 0000110;
    if ( ! chmod( $new_file, $perms ) ) {
        $message = sprintf( __( 'Failed to set file permissions: %s' ), substr( sprintf( '%o', $perms ), -3 ) );
        /**
         * Fires after an uploaded file's permissions are set.
         *
         * @since 3.9.0
         *
         * @param string $new_file Path to the newly uploaded file.
         * @param int    $perms    Permissions of the new file.
         * @param int    $error    Error code.
         */
        do_action( 'wp_handle_upload_permissions', $new_file, $perms, 1 );
        error_log( $message );
    }
  • chmod(): 这个函数用于设置文件的权限。 WordPress会尝试设置一个合理的权限,以确保文件可以被Web服务器访问,但又不会被恶意用户篡改。 通常是 644 (rw-r–r–)。
  • do_action( 'wp_handle_upload_permissions', ...): 这是一个动作钩子,允许开发者在文件权限设置之后执行一些自定义的操作。
  • 处理权限设置失败的情况: 如果权限设置失败,会记录一条错误日志。

第十一步:构建返回结果

    $url = $upload['url'] . "/$filename";

    return array( 'file' => $new_file, 'url' => $url, 'type' => $wp_filetype['type'] );
  • 构建URL: 根据上传目录的URL和文件名,构建文件的完整URL。
  • 返回结果数组: 返回一个数组,包含文件的路径、URL和MIME类型。

    键名 含义
    file 文件的完整路径(服务器上的绝对路径)
    url 文件的URL
    type 文件的MIME类型

安全 considerations

  • MIME类型验证: 虽然wp_check_filetype()会检查文件的MIME类型,但MIME类型是可以伪造的。 所以,仅仅依赖MIME类型验证是不够的。
  • 文件内容扫描: 为了更彻底地防止恶意文件上传,可以考虑使用文件内容扫描工具,例如ClamAV,来扫描上传的文件。
  • 禁用执行权限: 确保上传目录没有执行权限,这样即使上传了恶意脚本,也无法执行。
  • 输入验证和输出编码: 对所有用户输入进行验证,并对所有输出进行编码,以防止跨站脚本攻击(XSS)。

总结

wp_handle_upload()函数是WordPress文件上传的核心,它负责处理文件上传的各个环节,并进行安全验证。 通过对文件大小、文件类型、表单提交等进行检查,可以有效地防止恶意文件上传。

总的来说,wp_handle_upload()函数的设计思路是:

  1. 多重验证: 从文件大小、文件类型、表单提交等多个方面进行验证,确保上传的文件是安全的。
  2. 使用过滤器和动作钩子: 允许开发者自定义文件上传的行为,例如修改上传目录、修改允许上传的文件类型等。
  3. 安全第一: 在设计过程中,始终把安全放在第一位,采取多种措施防止恶意文件上传。

好了,今天的讲座就到这里。希望通过今天的讲解,大家对WordPress的文件上传机制有了更深入的了解。 记住,安全无小事,在开发过程中一定要时刻注意安全问题! 下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注