前言
上传功能出问题的形态很固定:用户传了一张 3 MB 的图,页面刷新后$_FILES是空的,连报错都没有;或者接口返回 413,可 PHP 日志里一条记录都没有;再或者明明在代码里写了"只允许 jpg",用户把shell.php改名为shell.jpg就传上来了。
先把版号这件事说清楚:文件上传的大小与数量限制不是 PHP 8.5 的新特性。upload_max_filesize、post_max_size、max_file_uploads、file_uploads这些是 php.ini(PHP 配置文件)里的指令,从 PHP 4/5 时代就存在,PHP 8.5 并没有为"上传格式限制"引入任何新语法或新指令。有些文章把上传配置写成"8.5 新特性",是不准确的。因此本文按"在当前 PHP(包括 8.5)上怎么做对"来讲,下面所有配置在 PHP 8.0 及以上都通用。
本文要解决三件事:搞清楚限制到底卡在哪一层(PHP 配置、Web 服务器、应用代码),为什么"只校验扩展名"等于没有校验,以及一份可以直接拿来用的上传处理代码。
一、限制分三层,报错却长得不一样
上传请求要依次穿过三层的限制,每层的失败表现完全不同,这是排查时最需要先分清的事:
| 层 | 关键配置 | 超限时的表现 |
|---|---|---|
| Web 服务器 | Nginxclient_max_body_size、ApacheLimitRequestBody | HTTP 413,PHP 完全收不到请求,日志无记录 |
| PHP 配置 | upload_max_filesize、post_max_size、max_file_uploads | $_FILES为空或error为非 0,常见UPLOAD_ERR_INI_SIZE |
| 应用代码 | 白名单、大小、MIME 校验 | 由你的代码决定,可以给出人类可读的错误 |
三个层次里,Nginx 的client_max_body_size默认是1m,这是"本地测试好好的、上线传大文件就 413"的头号原因;而post_max_size小于文件体积时,PHP 会在解析请求体前就放弃,导致$_POST和$_FILES双双为空——代码里如果只判断isset($_FILES['upload']),就会静默地什么都不做。
$_FILES['x']['error']的取值需要背下来,注意没有 5:
| 常量 | 值 | 含义 |
|---|---|---|
UPLOAD_ERR_OK | 0 | 成功 |
UPLOAD_ERR_INI_SIZE | 1 | 超过upload_max_filesize |
UPLOAD_ERR_FORM_SIZE | 2 | 超过表单里声明的MAX_FILE_SIZE |
UPLOAD_ERR_PARTIAL | 3 | 只上传了一部分 |
UPLOAD_ERR_NO_FILE | 4 | 没有选择文件 |
UPLOAD_ERR_NO_TMP_DIR | 6 | 缺少临时目录 |
UPLOAD_ERR_CANT_WRITE | 7 | 写磁盘失败 |
UPLOAD_ERR_EXTENSION | 8 | 被某个扩展中止 |
所以一个稳妥的判断顺序是:先看$_SERVER['CONTENT_LENGTH']是否超过post_max_size(超过时$_FILES为空,必须靠这个兜住),再看error码,最后才做业务校验。
二、为什么扩展名和白名单都必须校验,但都不能只靠一个
$_FILES['x']['type']是客户端提交的 MIME 类型,随便改一个请求就能伪造,绝不能作为安全依据;$_FILES['x']['name']同样是客户端给的,直接拿它拼路径就是目录穿越漏洞。判定的正确顺序是:
- 用
basename()剥掉客户端文件名里的路径,只用来做展示与日志; - 用
finfo(Fileinfo 扩展)读取服务器本地临时文件的真实内容类型,与白名单比对; - 对图片类,再用
getimagesize()确认它真能被解析为图片,并重新编码一次,顺手把"图片马"(polyglot file,即一半是图片一半是 PHP 代码的混合文件)里的附加数据洗掉; - 落盘时用随机生成的文件名 + 白名单里定死的扩展名,彻底丢弃客户端的命名。
其中第 3 步经常被忽略,但它是防"图片马"的关键:getimagesize()只看文件头,一个 GIF 头后面跟 PHP 代码的文件照样能通过;而用 GD 重新编码一张图,会丢掉所有非像素数据。
另外提醒一点:SVG 虽然以image/svg+xml通过校验,本质却是 XML,内联展示时可以执行脚本,属于 XSS(跨站脚本)风险。除非确有需求,建议把 SVG 排除在白名单之外,或者只允许下载、不允许内联渲染。
三、实战:可运行的上传处理器
下面这份代码把上面四步串起来,保存为upload.php(表单用 POST +multipart/form-data,字段名为upload)。需要 PHP 8.0 及以上;图片重编码需要 GD 扩展,代码里做了存在性判断,没有 GD 时会退化为"仅校验不重编码"。
<?php // upload.php —— 需要 PHP 8.0 及以上(建议开启 fileinfo 与 gd 扩展) declare(strict_types=1); const UPLOAD_DIR = __DIR__ . '/storage/uploads'; const MAX_BYTES = 8 * 1024 * 1024; // 单文件 8 MB,需小于 php.ini 的限制 /** 内容类型 => 落盘时使用的扩展名(白名单即"格式限制"的真正落点) */ const ALLOWED_TYPES = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', ]; function humanSize(int $bytes): string { return number_format($bytes / 1048576, 2) . ' MB'; } /** @return array{ok:bool, message:string, file?:string} */ function handleUpload(array $file): array { // 0) post_max_size 超限时 $_FILES 为空,这类情况由调用方提前兜住 $err = $file['error'] ?? UPLOAD_ERR_NO_FILE; if ($err !== UPLOAD_ERR_OK) { return ['ok' => false, 'message' => match ($err) { UPLOAD_ERR_INI_SIZE => '文件超过服务器 upload_max_filesize 限制(' . ini_get('upload_max_filesize') . ')', UPLOAD_ERR_FORM_SIZE => '文件超过表单声明的最大长度', UPLOAD_ERR_PARTIAL => '文件只上传了一部分,请重试', UPLOAD_ERR_NO_FILE => '没有选择文件', UPLOAD_ERR_NO_TMP_DIR => '服务器缺少临时目录,请联系管理员', UPLOAD_ERR_CANT_WRITE => '服务器写磁盘失败', UPLOAD_ERR_EXTENSION => '上传被某个 PHP 扩展中止', default => '未知上传错误', }]; } // 1) 大小与"确实是上传文件"的双重确认 if ($file['size'] > MAX_BYTES) { return ['ok' => false, 'message' => '文件过大,上限 ' . humanSize(MAX_BYTES)]; } if (!is_uploaded_file($file['tmp_name'])) { return ['ok' => false, 'message' => '临时文件校验失败']; } // 2) 用真实内容类型做白名单判断,绝不使用 $_FILES['type'] $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']) ?: 'application/octet-stream'; if (!isset(ALLOWED_TYPES[$mime])) { return ['ok' => false, 'message' => "不允许的格式: {$mime}"]; } // 3) 图片额外处理:解析 + 重编码,洗掉夹带的数据 $ext = ALLOWED_TYPES[$mime]; if (str_starts_with($mime, 'image/') && $mime !== 'image/svg+xml') { if (extension_loaded('gd')) { $data = file_get_contents($file['tmp_name']); $im = $data === false ? false : @imagecreatefromstring($data); if ($im === false) { return ['ok' => false, 'message' => '图片内容无法解析']; } imagealphablending($im, false); imagesavealpha($im, true); imagepng($im, $file['tmp_name']); // 重新编码,同时把格式统一为 png imagedestroy($im); $ext = 'png'; } else { // 无 GD 时至少确认文件头可解析 if (@getimagesize($file['tmp_name']) === false) { return ['ok' => false, 'message' => '图片内容无法解析']; } } } // 4) 落盘:随机文件名 + 白名单扩展名,客户端文件名只用于展示 if (!is_dir(UPLOAD_DIR) && !mkdir(UPLOAD_DIR, 0750, true) && !is_dir(UPLOAD_DIR)) { return ['ok' => false, 'message' => '上传目录不可用']; } $name = bin2hex(random_bytes(16)) . '.' . $ext; $dest = UPLOAD_DIR . DIRECTORY_SEPARATOR . $name; if (!move_uploaded_file($file['tmp_name'], $dest)) { return ['ok' => false, 'message' => '保存文件失败']; } chmod($dest, 0640); return ['ok' => true, 'message' => '上传成功', 'file' => $name]; } // ---- 入口 ---- if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); exit('请使用 POST 提交'); } // post_max_size 超限的兜底:此时 $_POST 与 $_FILES 都是空的 $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); if ($contentLength > 0 && empty($_POST) && empty($_FILES)) { http_response_code(413); exit('请求体过大(可能超过 post_max_size),请减小文件后重试'); } $result = handleUpload($_FILES['upload'] ?? []); http_response_code($result['ok'] ? 200 : 422); echo json_encode($result, JSON_UNESCAPED_UNICODE), PHP_EOL;配套的配置分两处。php.ini(或 php-fpm 池配置里的php_admin_value[...],因为upload_max_filesize与post_max_size属于PHP_INI_PERDIR,不能在脚本里用ini_set()改):
file_uploads = On upload_max_filesize = 8M post_max_size = 10M ; 必须大于 upload_max_filesize,且要容纳表单其他字段 max_file_uploads = 20 ; 单次请求最多文件数 upload_tmp_dir = /var/lib/php/uploads ; 需对 php-fpm 运行用户可写反向代理那一层同样要放行,否则请求根本到不了 PHP(表现为 413,且 PHP 日志干净):
server { listen 80; server_name upload.example.test; client_max_body_size 10m; # 默认 1m,需要与 post_max_size 对齐 location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 120s; # 大文件上传慢,别让网关先超时 } }常见坑点
- ❌ 只判断
isset($_FILES['upload'])就开始处理,post_max_size超限时$_FILES为空,程序静默什么都不做
✅ 先用$_SERVER['CONTENT_LENGTH']与ini_get('post_max_size')比对兜底,再进去看error码
- ❌ 信任
$_FILES['upload']['type']和['name']做格式判断与文件名
✅type由客户端提交,可随意伪造;name可能带路径。判定用finfo读本地临时文件,命名用随机串 + 白名单扩展名
- ❌ 用
strrchr($name, '.')取"最后一个点之后"就认为是安全后缀
✅ 双重扩展名(shell.php.jpg)与大小写混写都要靠白名单收口:不匹配允许的类型就直接拒绝,不做任何"容错解析"
- ❌ 在脚本里写
ini_set('upload_max_filesize', '50M')想放宽限制,结果毫无效果
✅ 它是PHP_INI_PERDIR级别,只能在 php.ini、.htaccess、.user.ini或 php-fpm 池配置里设置,改完重启进程
- ❌ 只放大了
upload_max_filesize,忘了同步加大post_max_size和 Nginx 的client_max_body_size
✅ 三处要一起放大,且post_max_size略大于upload_max_filesize,代理层不小于post_max_size
- ❌ 把上传目录放在 Web 根目录下,还允许按原文件名保存
✅ 目录放在 Web 根之外(或用 Nginx 规则禁止该目录执行脚本),文件名随机化,避免"上传即执行"的链条成立
- ❌ 只用
getimagesize()判断图片,接受 GIF/PNG 混合文件
✅ 有 GD 时用imagecreatefromstring()+imagepng()重新编码,物理上抹掉夹带内容
- ❌ 忘记
mkdir返回值判断,也没给upload_tmp_dir足够权限,用户看到的是UPLOAD_ERR_CANT_WRITE却没有日志
✅ 检查upload_tmp_dir与目标目录的属主/权限,并把error码翻译成人话返回给用户,日志里留下原始码
总结
| 限制点 | 配置项 | 传错/配错的症状 |
|---|---|---|
| 代理层请求体 | Nginxclient_max_body_size | 413,PHP 无日志 |
| 单文件大小 | upload_max_filesize | UPLOAD_ERR_INI_SIZE(1) |
| 整个请求体 | post_max_size | $_POST/$_FILES同时为空 |
| 文件个数 | max_file_uploads | 超出部分被静默丢弃 |
| 临时目录 | upload_tmp_dir | UPLOAD_ERR_NO_TMP_DIR(6)或写失败(7) |
| 格式限制 | 应用层finfo白名单 + 重编码 | 伪造扩展名与图片马绕过 |
记住一句话:PHP 配置负责"能收多大的文件",Web 服务器负责"请求体放不放行",应用代码负责"这个文件到底是不是它自称的格式"。三层各管一段,任何一层缺位都会出现文中那些"没有报错却失败"的诡异现象。至于版号,上传限制跟 PHP 8.5 没有特殊关系,你在 PHP 8.0 上照这套配置做,行为是一致的。