☰
PHP8.5怎么配置文件上传格式限制
2026/10/3 9:26:35 网站建设 项目流程

前言

上传功能出问题的形态很固定:用户传了一张 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、ApacheLimitRequestBodyHTTP 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_OK0成功
UPLOAD_ERR_INI_SIZE1超过upload_max_filesize
UPLOAD_ERR_FORM_SIZE2超过表单里声明的MAX_FILE_SIZE
UPLOAD_ERR_PARTIAL3只上传了一部分
UPLOAD_ERR_NO_FILE4没有选择文件
UPLOAD_ERR_NO_TMP_DIR6缺少临时目录
UPLOAD_ERR_CANT_WRITE7写磁盘失败
UPLOAD_ERR_EXTENSION8被某个扩展中止

所以一个稳妥的判断顺序是:先看$_SERVER['CONTENT_LENGTH']是否超过post_max_size(超过时$_FILES为空,必须靠这个兜住),再看error码,最后才做业务校验。

二、为什么扩展名和白名单都必须校验,但都不能只靠一个

$_FILES['x']['type']是客户端提交的 MIME 类型,随便改一个请求就能伪造,绝不能作为安全依据;$_FILES['x']['name']同样是客户端给的,直接拿它拼路径就是目录穿越漏洞。判定的正确顺序是:


  1. 用basename()剥掉客户端文件名里的路径,只用来做展示与日志;

  2. 用finfo(Fileinfo 扩展)读取服务器本地临时文件的真实内容类型,与白名单比对;

  3. 对图片类,再用getimagesize()确认它真能被解析为图片,并重新编码一次,顺手把"图片马"(polyglot file,即一半是图片一半是 PHP 代码的混合文件)里的附加数据洗掉;

  4. 落盘时用随机生成的文件名 + 白名单里定死的扩展名,彻底丢弃客户端的命名。


其中第 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; # 大文件上传慢,别让网关先超时 } }

常见坑点


  1. ❌ 只判断isset($_FILES['upload'])就开始处理,post_max_size超限时$_FILES为空,程序静默什么都不做


✅ 先用$_SERVER['CONTENT_LENGTH']与ini_get('post_max_size')比对兜底,再进去看error码


  1. ❌ 信任$_FILES['upload']['type']和['name']做格式判断与文件名


✅type由客户端提交,可随意伪造;name可能带路径。判定用finfo读本地临时文件,命名用随机串 + 白名单扩展名


  1. ❌ 用strrchr($name, '.')取"最后一个点之后"就认为是安全后缀


✅ 双重扩展名(shell.php.jpg)与大小写混写都要靠白名单收口:不匹配允许的类型就直接拒绝,不做任何"容错解析"


  1. ❌ 在脚本里写ini_set('upload_max_filesize', '50M')想放宽限制,结果毫无效果


✅ 它是PHP_INI_PERDIR级别,只能在 php.ini、.htaccess、.user.ini或 php-fpm 池配置里设置,改完重启进程


  1. ❌ 只放大了upload_max_filesize,忘了同步加大post_max_size和 Nginx 的client_max_body_size


✅ 三处要一起放大,且post_max_size略大于upload_max_filesize,代理层不小于post_max_size


  1. ❌ 把上传目录放在 Web 根目录下,还允许按原文件名保存


✅ 目录放在 Web 根之外(或用 Nginx 规则禁止该目录执行脚本),文件名随机化,避免"上传即执行"的链条成立


  1. ❌ 只用getimagesize()判断图片,接受 GIF/PNG 混合文件


✅ 有 GD 时用imagecreatefromstring()+imagepng()重新编码,物理上抹掉夹带内容


  1. ❌ 忘记mkdir返回值判断,也没给upload_tmp_dir足够权限,用户看到的是UPLOAD_ERR_CANT_WRITE却没有日志


✅ 检查upload_tmp_dir与目标目录的属主/权限,并把error码翻译成人话返回给用户,日志里留下原始码

总结

限制点配置项传错/配错的症状
代理层请求体Nginxclient_max_body_size413,PHP 无日志
单文件大小upload_max_filesizeUPLOAD_ERR_INI_SIZE(1)
整个请求体post_max_size$_POST/$_FILES同时为空
文件个数max_file_uploads超出部分被静默丢弃
临时目录upload_tmp_dirUPLOAD_ERR_NO_TMP_DIR(6)或写失败(7)
格式限制应用层finfo白名单 + 重编码伪造扩展名与图片马绕过

记住一句话:PHP 配置负责"能收多大的文件",Web 服务器负责"请求体放不放行",应用代码负责"这个文件到底是不是它自称的格式"。三层各管一段,任何一层缺位都会出现文中那些"没有报错却失败"的诡异现象。至于版号,上传限制跟 PHP 8.5 没有特殊关系,你在 PHP 8.0 上照这套配置做,行为是一致的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询