PHP 中的安全图片上传
上传端点让陌生人可以向您的服务器写入文件。在 PHP 中,最坏的结果很具体:某个文件之后被 Web 服务器当作 PHP 运行。本页逐一介绍示例 rte/upload.php 中的检查,说明每项检查能拦住什么、拦不住什么,最后介绍最重要的一条规则:uploads/ 中的任何内容都绝不能被执行。
不要相信浏览器的说法
$_FILES['file']['name'] 和 $_FILES['file']['type'] 都来自请求。客户端可以发送任意文件名和任意类型。我们把一个内容为 <?php echo "hello"; ?> 的文件以 holiday.png 的名称、type=image/png 的类型发送。仅检查 type 的话,它会被接受。示例完全忽略 type,转而检查字节内容:
// The name decides the extension; the bytes must agree with it.
// $file['type'] is whatever the browser (or attacker) sent, so it is ignored.
$ext = rte_extension((string)($file['name'] ?? ''));
if (!in_array($ext, RTE_IMAGE_EXTENSIONS, true)) {
throw new RteHttpError(400, 'Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.');
}
$types = ['jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'webp' => IMAGETYPE_WEBP];
// Signature first, then getimagesize() (core PHP, no GD needed) parses the header.
// A bare signature followed by junk still parses, but as 0x0, so require real dimensions.
$info = @getimagesize($file['tmp_name']);
if (!rte_signature_matches($file['tmp_name'], $ext) || $info === false
|| $info[2] !== $types[$ext] || $info[0] < 1 || $info[1] < 1) {
throw new RteHttpError(400, 'The file is not a valid .' . $ext . ' image.');
}扩展名必须是五种之一,并且文件开头的字节必须与之相符:
// The first bytes of the file must match its extension.
function rte_signature_matches(string $file, string $ext): bool
{
$handle = fopen($file, 'rb');
if ($handle === false) {
return false;
}
$head = (string)fread($handle, 12);
fclose($handle);
return match ($ext) {
'jpg', 'jpeg' => str_starts_with($head, "\xFF\xD8\xFF"),
'png' => str_starts_with($head, "\x89PNG\r\n\x1A\n"),
'gif' => str_starts_with($head, 'GIF87a') || str_starts_with($head, 'GIF89a'),
'webp' => str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP',
default => false,
};
}为什么要用 getimagesize() 并检查 0 x 0
getimagesize() 读取图片头部,报告类型和尺寸。它是 PHP 核心的一部分,因此不需要 GD 或 fileinfo 也能使用。但单靠它很容易蒙混过关:PNG 签名后面跟着 3 MB 的零字节,就能以 image/png 的身份通过 getimagesize(),宽度和高度均为 0。因此示例还要求宽高都至少为 1。
这两项检查都不能让文件变得无害。我们在一个真实的 1×1 PNG 末尾追加了 <?php echo "EXECUTED"; ?>。它的签名有效、尺寸有效,而 PHP 代码仍然在里面。示例保存了它,任何图片检查都会这样。让这一点无关紧要的,是接下来的两条规则。
生成的文件名
保存的文件名由服务器构造:原始文件名转为小写,只保留 a-z、0-9 和 -,接着是八个随机十六进制数字,最后是经过检查的扩展名。Red Square.png 会变成类似 red-square-abc60cfb.png 的名称。因此,服务器永远不会写入带第二个点的文件名(shell.php.png),永远不会覆盖已有文件,也永远不会保留客户端选择的名称。名为 shell.php 的文件在此之前就会被拒绝:400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}。

holiday.jpg 的脚本被服务器拒绝。对话框显示的是它自己的消息;服务器给出的原因在响应体中。绝不运行 uploads/ 中的任何内容
有了这条规则,即使其他检查出了差错也不会造成危害。配置 Web 服务器,使它对 uploads/ 中的文件只能以字节形式发送。在 Apache 上,把示例中的 uploads.htaccess 复制为 uploads/.htaccess;更好的做法是把同样的配置写进虚拟主机的 <Directory> 中:
# 1. Only image files can be requested. Everything else (x.php, x.phtml, .htaccess) -> 403,
# whatever handler (mod_php, PHP-FPM, CGI) the server uses for .php.
Require all denied
<FilesMatch "(?i)\.(jpe?g|png|gif|webp)$">
Require all granted
</FilesMatch>
# 2. Even an allowed name must not reach PHP. "AddHandler ... .php" also matches
# "x.php.png" (mod_mime looks at every extension), so drop those mappings here.
RemoveHandler .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
RemoveType .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes -ExecCGI -Includes
# 3. Browsers must not guess a different type from the bytes.
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>RemoveHandler 这一行针对的是 Apache 的一个经典陷阱。AddHandler application/x-httpd-php .php 会匹配文件名中的每一个扩展名,因此 photo.php.png 会被当作 PHP 运行。在 nginx 上,关键在于 ^~ 前缀:
# ^~ : once a URL starts with /uploads/, nginx skips regex locations, so a
# "location ~ \.php$" block can never run an uploaded file.
location ^~ /uploads/ {
deny all;
location ~* \.(jpe?g|png|gif|webp)$ {
allow all;
add_header X-Content-Type-Options nosniff always;
}
}
# Nothing else is PHP-executable (rte/files-lib.php, router.php, demo-login.php).
location ~ \.php$ { return 404; }如果没有 ^~,配置中其他位置的常规 location ~ \.php$ { fastcgi_pass ...; } 会优先于普通的 location /uploads/,/uploads/x.php 就会被执行。另外,请在 PHP-FPM 池中保留 security.limit_extensions = .php,这样即使其他某条规则把 photo.png 交给 FPM,FPM 也会拒绝运行它。
PHP 内置的开发服务器也有同样的陷阱,只是形式不同。如果路由脚本返回 false,服务器会自己处理文件,并运行 .php 文件。示例的路由脚本自己提供 /uploads/,而且只提供图片。我们验证过:/uploads/probe.php 返回 404,/uploads/probe.php.png 以 image/png 字节返回,没有被运行。
我们在 Windows 上用 PHP 8.2 测试了内置服务器的配置。上面的 Apache 和 nginx 规则没有在那台机器上运行,因为那里没有安装这两种服务器。请在您自己的服务器上测试:在 uploads/ 中放一个 test.php 并请求它。应该得到 403 或 404,而绝不应该看到它的输出。
文件夹和路径
folder 字段是一个虚拟路径,会逐段检查,然后在存储根目录内用 realpath() 解析。这样可以拒绝 ..、绝对路径、驱动器号、反斜杠,以及指向外部的符号链接或目录联接(junction)。文件夹指南给出了代码,以及我们在 Windows 目录联接上观察到的结果。
示例留给您处理的部分
示例不做任何登录,它的两个 POST 路由也没有 CSRF 令牌。rte-files.js 发送的是不带额外标头的普通 XMLHttpRequest。因此,如果框架的 CSRF 中间件对每个 POST 都生效(例如 Laravel 的 web 路由),就会拒绝这些请求,除非您把这些路由排除在外或自行添加令牌。请在 rte_root_for_request() 之前检查用户。每个用户的文件夹指南介绍了这部分代码放在哪里。
测试
在端口 5201 上运行本指南的示例项目,然后执行:
printf '<?php echo "hello"; ?>' > holiday.png
curl -F "file=@holiday.png;type=image/png" http://localhost:5201/rte/upload
{"error":"The file is not a valid .png image."}
curl -F "file=@holiday.png;filename=shell.php" http://localhost:5201/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}两个响应都是 HTTP 400。共享的契约测试会发送同类文件(以 .png 命名的脚本、.svg、.php、路径穿越的文件夹),并期望每一个都被拒绝:node shared/contract-test.mjs http://localhost:5201 最后输出 ALL 35 CHECKS PASSED。
其他平台的同一指南
JavaScript · Next.js · ASP.NET Core · Razor Pages · MVC 5 · Web Forms