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

各位听众,早上好!我是你们今天的 WordPress 文件上传安全讲师,代号“代码猎手”。今天咱们来聊聊 WordPress 源码里一个既重要又容易被忽视的函数:wp_handle_upload()。这玩意儿啊,是 WordPress 文件上传的核心,但如果理解不透彻,分分钟给你挖个大坑,让黑客叔叔请你喝茶。

咱们今天就把它扒个底朝天,看看它到底是怎么处理文件上传的,又是怎么进行安全验证的,以及我们开发者在使用的时候应该注意哪些地方,才能保证咱网站的安全。

开场白:文件上传,甜蜜的负担

文件上传功能,是很多网站的标配。用户上传头像,上传简历,上传各种文件,方便是方便了,但同时也给网站带来了安全隐患。想象一下,如果有人上传一个恶意脚本,那可就完犊子了。

所以,WordPress 官方也深知这一点,wp_handle_upload() 函数就是一道安全屏障,它负责接收用户上传的文件,进行一系列的安全检查,然后把文件保存到指定的位置。

wp_handle_upload() 的庐山真面目

wp_handle_upload() 函数位于 wp-admin/includes/file.php 文件中。咱们先来看看它的基本结构:

function wp_handle_upload( $file, $overrides = false, $time = null ) {
    // 1. 预处理和错误检查
    // 2. 覆盖参数处理
    // 3. 临时文件路径处理
    // 4. 文件信息提取
    // 5. 文件类型检查
    // 6. 上传目录检查和创建
    // 7. 重命名文件
    // 8. 保存文件
    // 9. 后处理和返回结果

    // 为了方便理解,我们逐个分解
    // ...
}

看到了吧,这个函数流程还是比较清晰的,主要分为这几个步骤:预处理、参数处理、文件信息提取、类型检查、目录处理、重命名、保存和后处理。下面我们一步步来分析。

1. 预处理和错误检查

首先,函数会进行一些预处理和基本的错误检查。比如,检查上传的文件是否存在,是否有错误代码等等。

if ( ! is_array( $file ) || ! isset( $file['tmp_name'] ) || ! isset( $file['name'] ) ) {
    return array( 'error' => __( 'Invalid file.' ) );
}

// 检查是否有上传错误
if ( ! empty( $file['error'] ) ) {
    return array( 'error' => wp_get_upload_error( $file['error'] ) );
}

// 检查临时文件是否存在
if ( ! @is_uploaded_file( $file['tmp_name'] ) ) {
    return array( 'error' => __( 'Could not move the file.' ) );
}

这段代码很简单,就是检查 $file 数组是否包含了必要的信息,以及检查上传过程中是否发生了错误。如果发现错误,就直接返回一个错误信息。wp_get_upload_error() 函数用于将 PHP 的上传错误代码转换为用户友好的错误信息。

2. 覆盖参数处理

wp_handle_upload() 函数允许我们通过 $overrides 参数来覆盖一些默认的行为。这个参数是一个数组,可以包含以下几个键:

  • test_form: 是否跳过 $_POST 表单检查。默认为 true
  • test_size: 是否跳过文件大小检查。默认为 true
  • test_upload: 是否跳过文件上传检查。默认为 true
  • unique_filename_callback: 自定义文件名生成函数。
  • upload_error_strings: 自定义错误信息。
$defaults = array(
    'test_form'   => true,
    'test_size'   => true,
    'test_upload' => true,
);

$overrides = wp_parse_args( $overrides, $defaults );

extract( $overrides, EXTR_SKIP );

这段代码使用 wp_parse_args() 函数将 $overrides 数组和 $defaults 数组合并,然后使用 extract() 函数将 $overrides 数组中的键提取为变量。这样我们就可以在后面的代码中使用 $test_form$test_size$test_upload 这些变量来控制上传的行为。

3. 临时文件路径处理

接下来,函数会获取临时文件的路径。

$tmp_name = $file['tmp_name'];

这个很简单,就是把 $file['tmp_name'] 赋值给 $tmp_name 变量。这个变量在后面会被用来移动文件。

4. 文件信息提取

这一步,函数会提取一些重要的文件信息,比如文件名、文件类型等等。

$name = basename( $file['name'] );

preg_match( '/[^x00-x7F]/', $name, $matches );
if ( ! empty( $matches ) ) {
    $name = preg_replace( '/[^x00-x7F]+/i', '', $name );
    $name = trim( $name, '.' );
    if ( empty( $name ) ) {
        return array( 'error' => __( 'Invalid file name.' ) );
    }
}

$file_ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );

这段代码首先使用 basename() 函数获取文件名,然后使用正则表达式检查文件名中是否包含非 ASCII 字符。如果包含,就将其替换为空字符串。最后,使用 pathinfo() 函数获取文件扩展名,并将其转换为小写。

5. 文件类型检查:重头戏来了!

文件类型检查是安全验证的关键环节。wp_handle_upload() 函数会使用 wp_check_filetype() 函数来检查文件的 MIME 类型和扩展名,并判断是否允许上传。

$wp_filetype = wp_check_filetype( $name, null );

$ext  = empty( $wp_filetype['ext'] )  ? '' : $wp_filetype['ext'];
$type = empty( $wp_filetype['type'] ) ? '' : $wp_filetype['type'];

if ( $ignore_types === true ) {
    unset( $ext, $type, $proper_filename );
}

if ( ! $ext && ! ( current_user_can( 'unfiltered_upload' ) && ! empty( $type ) ) ) {
    return array( 'error' => __( 'File type is not allowed.' ) );
}

这段代码首先使用 wp_check_filetype() 函数获取文件的扩展名和 MIME 类型。然后,检查用户是否具有 unfiltered_upload 权限。如果用户没有这个权限,并且文件没有有效的扩展名,就返回一个错误信息。

wp_check_filetype() 函数本身也包含一些安全检查,它会根据文件的内容来判断文件的真实类型,而不是仅仅依赖于文件扩展名。这可以防止一些简单的文件伪装攻击。

wp_check_filetype() 函数内部:MIME 类型与扩展名的博弈

wp_check_filetype() 函数的核心逻辑在于它如何判断文件的类型。它主要依赖于两个函数:wp_get_mime_types()apply_filters( 'upload_mimes', $mime_types )

  • wp_get_mime_types(): 这个函数返回一个数组,包含了各种文件扩展名和对应的 MIME 类型。
  • apply_filters( 'upload_mimes', $mime_types ): 这个函数允许我们通过 filter 来修改允许上传的 MIME 类型。
function wp_check_filetype( $filename, $mimes = null ) {
    $wp_filetype = array(
        'ext'  => false,
        'type' => false,
        'proper_filename' => false
    );

    $filename_parts = pathinfo( $filename );
    if ( isset( $filename_parts['extension'] ) ) {
        $ext = strtolower( $filename_parts['extension'] );
        $mime_types = wp_get_mime_types();

        if ( isset( $mime_types[ $ext ] ) ) {
            $wp_filetype['ext'] = $ext;
            $wp_filetype['type'] = $mime_types[ $ext ];
        }
    }
    return apply_filters( 'wp_check_filetype', $wp_filetype, $filename, $mimes );
}

这个函数首先获取文件的扩展名,然后从 $mime_types 数组中查找对应的 MIME 类型。如果找到了,就将扩展名和 MIME 类型保存到 $wp_filetype 数组中。最后,通过 apply_filters() 函数来允许开发者修改 $wp_filetype 数组。

安全提示:upload_mimes filter 的妙用与风险

upload_mimes filter 是一个非常有用的工具,允许我们自定义允许上传的文件类型。但是,如果使用不当,也会带来安全风险。

比如,如果我们允许上传 svg 文件,就必须确保服务器已经正确配置了 MIME 类型,并且对 svg 文件进行了安全处理,以防止 XSS 攻击。

代码示例:限制上传类型

add_filter( 'upload_mimes', 'my_custom_mime_types' );

function my_custom_mime_types( $mime_types ) {
    $mime_types = array(
        'jpg|jpeg|jpe' => 'image/jpeg',
        'gif'          => 'image/gif',
        'png'          => 'image/png',
    );
    return $mime_types;
}

这段代码使用 upload_mimes filter 来只允许上传 jpggifpng 文件。

6. 上传目录检查和创建

接下来,函数会检查上传目录是否存在,如果不存在,就创建它。

$upload_dir = wp_upload_dir( $time );

if ( $upload_dir['error'] ) {
    return array( 'error' => $upload_dir['error'] );
}

$upload_path = $upload_dir['path'];
$upload_url  = $upload_dir['url'];

$is_yearmonth = ( false === strpos( $upload_dir['basedir'], '[year]' ) && false === strpos( $upload_dir['basedir'], '[month]' ) );

if ( ( ( ! empty( $ext ) && $is_yearmonth ) || ! $is_yearmonth ) && ! wp_mkdir_p( $upload_path ) ) {
    return array( 'error' => sprintf( __( 'Could not create directory %s. Is its parent directory writable by the server?' ), esc_html( $upload_path ) ) );
}

这段代码首先使用 wp_upload_dir() 函数获取上传目录的信息。然后,检查上传目录是否存在,如果不存在,就使用 wp_mkdir_p() 函数创建它。wp_mkdir_p() 函数可以递归地创建目录,类似于 mkdir -p 命令。

7. 重命名文件:避免冲突,安全第一

为了避免文件名冲突,wp_handle_upload() 函数会对上传的文件进行重命名。

$filename = wp_unique_filename( $upload_path, $name, $unique_filename_callback );

$new_file = trailingslashit( $upload_path ) . $filename;

这段代码使用 wp_unique_filename() 函数生成一个唯一的文件名。wp_unique_filename() 函数会在文件名后面添加一个递增的数字,直到找到一个不存在的文件名。

wp_unique_filename() 函数内部:寻找独一无二的你

wp_unique_filename() 函数的核心逻辑在于它如何生成唯一的文件名。它会首先检查文件名是否存在,如果存在,就在文件名后面添加一个递增的数字,直到找到一个不存在的文件名。

function wp_unique_filename( $dir, $filename, $unique_filename_callback = null ) {
    $filename = wp_basename( $filename );
    if ( is_callable( $unique_filename_callback ) ) {
        $filename = call_user_func( $unique_filename_callback, $dir, $filename );
    } else {
        $number = '';
        $fileparts = pathinfo( $filename );
        $name = rtrim( $fileparts['basename'], '.' . $fileparts['extension'] );
        $ext = isset( $fileparts['extension'] ) ? '.' . $fileparts['extension'] : '';
        while ( file_exists( $dir . "/$filename" ) ) {
            $number++;
            $new_file_name = $name . $number . $ext;
            $filename = $new_file_name;
        }
    }
    return $filename;
}

这个函数首先使用 wp_basename() 函数获取文件名。然后,检查是否定义了 $unique_filename_callback 函数。如果定义了,就使用该函数生成唯一的文件名。否则,就使用默认的算法生成唯一的文件名。

安全提示:自定义文件名生成函数的注意事项

如果我们使用 $unique_filename_callback 参数来自定义文件名生成函数,就必须确保该函数能够生成唯一的文件名,并且要防止文件名中包含恶意字符,比如 ../

8. 保存文件:安全落地,步步为营

终于到了保存文件的环节。wp_handle_upload() 函数会使用 move_uploaded_file() 函数将临时文件移动到指定的位置。

$move_new_file = @move_uploaded_file( $tmp_name, $new_file );

if ( false === $move_new_file ) {
    return array( 'error' => sprintf( __( 'Could not move the file %s to %s.' ), esc_html( $tmp_name ), esc_html( $new_file ) ) );
}

这段代码使用 @move_uploaded_file() 函数将临时文件移动到 $new_file 指定的位置。@ 符号用于抑制错误信息。

安全提示:move_uploaded_file() 函数的重要性

move_uploaded_file() 函数是 PHP 中用于移动上传文件的安全函数。它会检查上传的文件是否是通过 HTTP POST 上传的,以防止恶意用户通过其他方式上传文件。

9. 后处理和返回结果:功成身退

最后,wp_handle_upload() 函数会进行一些后处理,比如设置文件的权限,并返回上传结果。

$stat = stat( dirname( $new_file ) );
$perms = $stat['mode'] & 0007777;
$perms = $perms & 0000777 | 0000644;
@chmod( $new_file, $perms );

$url = str_replace( trailingslashit( WP_CONTENT_DIR ), trailingslashit( WP_CONTENT_URL ), $new_file );

$return = array( 'file' => $filename, 'url' => $url, 'type' => $type );

return $return;

这段代码首先使用 stat() 函数获取文件所在目录的权限,然后使用 chmod() 函数设置文件的权限。最后,生成文件的 URL,并返回一个包含文件信息的数组。

代码总结:wp_handle_upload() 的完整代码

为了方便大家理解,我把 wp_handle_upload() 函数的完整代码放在这里:

function wp_handle_upload( $file, $overrides = false, $time = null ) {
    if ( ! is_array( $file ) || ! isset( $file['tmp_name'] ) || ! isset( $file['name'] ) ) {
        return array( 'error' => __( 'Invalid file.' ) );
    }

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

    if ( ! @is_uploaded_file( $file['tmp_name'] ) ) {
        return array( 'error' => __( 'Could not move the file.' ) );
    }

    $defaults = array(
        'test_form'   => true,
        'test_size'   => true,
        'test_upload' => true,
        'unique_filename_callback' => null,
        'upload_error_strings' => null
    );

    $overrides = wp_parse_args( $overrides, $defaults );

    extract( $overrides, EXTR_SKIP );

    $tmp_name = $file['tmp_name'];

    $name = basename( $file['name'] );

    preg_match( '/[^x00-x7F]/', $name, $matches );
    if ( ! empty( $matches ) ) {
        $name = preg_replace( '/[^x00-x7F]+/i', '', $name );
        $name = trim( $name, '.' );
        if ( empty( $name ) ) {
            return array( 'error' => __( 'Invalid file name.' ) );
        }
    }

    $wp_filetype = wp_check_filetype( $name, null );

    $ext  = empty( $wp_filetype['ext'] )  ? '' : $wp_filetype['ext'];
    $type = empty( $wp_filetype['type'] ) ? '' : $wp_filetype['type'];

    if ( ! $ext && ! ( current_user_can( 'unfiltered_upload' ) && ! empty( $type ) ) ) {
        return array( 'error' => __( 'File type is not allowed.' ) );
    }

    $upload_dir = wp_upload_dir( $time );

    if ( $upload_dir['error'] ) {
        return array( 'error' => $upload_dir['error'] );
    }

    $upload_path = $upload_dir['path'];
    $upload_url  = $upload_dir['url'];

    $is_yearmonth = ( false === strpos( $upload_dir['basedir'], '[year]' ) && false === strpos( $upload_dir['basedir'], '[month]' ) );

    if ( ( ( ! empty( $ext ) && $is_yearmonth ) || ! $is_yearmonth ) && ! wp_mkdir_p( $upload_path ) ) {
        return array( 'error' => sprintf( __( 'Could not create directory %s. Is its parent directory writable by the server?' ), esc_html( $upload_path ) ) );
    }

    $filename = wp_unique_filename( $upload_path, $name, $unique_filename_callback );

    $new_file = trailingslashit( $upload_path ) . $filename;

    $move_new_file = @move_uploaded_file( $tmp_name, $new_file );

    if ( false === $move_new_file ) {
        return array( 'error' => sprintf( __( 'Could not move the file %s to %s.' ), esc_html( $tmp_name ), esc_html( $new_file ) ) );
    }

    $stat = stat( dirname( $new_file ) );
    $perms = $stat['mode'] & 0007777;
    $perms = $perms & 0000777 | 0000644;
    @chmod( $new_file, $perms );

    $url = str_replace( trailingslashit( WP_CONTENT_DIR ), trailingslashit( WP_CONTENT_URL ), $new_file );

    $return = array( 'file' => $filename, 'url' => $url, 'type' => $type );

    return $return;
}

总结:安全无小事,细节决定成败

wp_handle_upload() 函数是 WordPress 文件上传的核心,它负责接收用户上传的文件,进行一系列的安全检查,然后把文件保存到指定的位置。

我们在使用 wp_handle_upload() 函数的时候,需要注意以下几点:

  • 限制上传的文件类型: 使用 upload_mimes filter 来限制允许上传的文件类型,防止恶意用户上传危险的文件。
  • 自定义文件名生成函数: 如果需要自定义文件名生成函数,要确保该函数能够生成唯一的文件名,并且要防止文件名中包含恶意字符。
  • 文件权限: 确保上传的文件具有正确的权限,防止恶意用户修改或删除文件。
  • 服务器配置: 确保服务器已经正确配置了 MIME 类型,并且对上传的文件进行了安全处理,以防止 XSS 攻击。

总而言之,文件上传安全是一个复杂的问题,需要我们从多个方面进行考虑,才能保证网站的安全。

Q&A 环节

好了,今天的讲座就到这里。大家有什么问题吗?可以提出来,我们一起讨论。

(等待听众提问)

如果没有问题,那咱们就下课啦!希望今天的讲座对大家有所帮助。记住,安全无小事,细节决定成败!下次再见!

发表回复

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