学完这篇,你能从零写出一套可以上线用的 PHP 文件上传功能,并且知道每一行校验在防什么,而不是照抄一句 `move_uploaded_file` 就收工。
第一步:先弄清上传经过哪三个环节
浏览器把文件塞进 `multipart/form-data` 请求 → PHP 先把文件落到临时目录并填好 `$_FILES` → 你的脚本校验通过后,用 `move_uploaded_file` 挪到正式目录。
新手最容易懵的一点是:文件是先落地、再进你代码的。所以「文件已经在服务器上了」不代表它是合法的,校验没做完之前它始终是可疑文件。
`$_FILES['file']` 固定五个字段:
- `name` 客户端原始文件名(可伪造,只能用于展示)
- `type` 客户端声明的 MIME(可伪造,别信)
- `tmp_name` 临时文件路径(真正要操作的是它)
- `error` 错误码,等于 0 才是成功
- `size` 字节数
第二步:写前端表单
<form action="/upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="image/jpeg,image/png,image/webp" multiple>
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<button type="submit">上传</button>
</form>
`enctype` 少一个字母,后端就只能收到文件名、收不到文件,这是最高频的低级错误。`accept` 只负责把文件选择框过滤成图片,`MAX_FILE_SIZE` 只是给浏览器的软提示。
想要进度条得用 `XMLHttpRequest`,监听 `xhr.upload.onprogress`;`fetch` 目前拿不到上传进度。
注意:前端校验是体验优化,不是安全边界。F12 改一行 JS 就绕过去了,真正的把关必须在后端。
第三步:后端接住,先看错误码
$f = $_FILES['file'] ?? null;
if (!$f) {
exit('没有收到文件(可能是 post_max_size 太小,请求被丢弃)');
}
if ($f['error'] !== UPLOAD_ERR_OK) {
// 1 超 upload_max_filesize,2 超表单 MAX_FILE_SIZE
// 3 只传了一半,4 没选文件,6 缺临时目录,7 写盘失败,8 被扩展拦截
exit('上传失败,错误码:' . $f['error']);
}
if (!is_uploaded_file($f['tmp_name'])) {
exit('非法上传来源');
}
注意:当文件体积超过 `post_max_size` 时,PHP 会把整个请求丢掉,`$_FILES` 和 `$_POST` 都是空的,错误码你都拿不到。所以这里要专门判断 `!$f` 这种情况,否则报错信息会非常误导人。
第四步:安全校验三层,缺一层都不行
第一层,大小与数量:`$f['size'] > 5*1024*1024` 直接拒,同时用 `max_file_uploads` 限制单次数量。
第二层,扩展名白名单:
$allow = ['jpg','jpeg','png','gif','webp'];
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allow, true)) exit('格式不允许');
一定要用白名单,不能用黑名单。黑名单永远漏:`phtml`、`php5`、`php.`(结尾点)、`file.php.jpg` 后面再跟解析漏洞,都是老套路。
第三层,MIME 与内容二次校验:
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
if (!in_array($mime, ['image/jpeg','image/png','image/gif','image/webp'], true)) {
exit('文件内容与扩展名不符');
}
if (getimagesize($f['tmp_name']) === false) {
exit('不是有效图片');
}
`finfo` 读的是文件头字节,伪造不了;把图片塞进 `imagecreatefromxxx()` 再重新编码输出一遍,是最彻底的洗白方式(木马代码在重编码过程中必然被丢弃)。
第五步:命名与落盘
$dir = __DIR__ . '/uploads/' . date('Ym') . '/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$name = bin2hex(random_bytes(16)) . '.' . $ext; // 绝不用原始文件名
$dest = $dir . $name;
if (!move_uploaded_file($f['tmp_name'], $dest)) {
exit('保存失败:uploads 目录不可写');
}
原始文件名里有中文、空格、`../`、空字节,一律不用它,问题一次性全消。
注意:上传目录必须禁止执行 PHP。Nginx 加 `location ^~ /uploads/ { location ~ \.php$ { deny all; } }`,Apache 在目录里放 `.htaccess` 写 `php_flag engine off`。做了这一步,就算白名单哪天被绕过,脚本也执行不起来。
第六步:把失败原因说清楚
用户上传失败时,页面要能明确告诉他是哪一种:超过 PHP 上限(提示去调 `upload_max_filesize` / `post_max_size`,面板默认 2M,手机照片经常超,建议调到 30M+)、不在允许白名单、目录不可写。笼统回一句"上传失败",排查成本会翻十倍。
另外提醒一句架构习惯:图片扩展名白名单和附件扩展名白名单要分成两个独立配置项。图片能被 `<img>` 直接渲染,风险等级和 zip、pdf 完全不同,混在一起配迟早出事。
小结
- 上传是「先落地再校验」,校验没通过前文件都是可疑的
- `$_FILES['type']`、原始文件名、前端 `accept` 全部不可信
- 三层校验:大小数量 → 扩展名白名单 → `finfo` + `getimagesize` 内容校验
- 存盘用随机名 + 日期目录,绝不沿用用户文件名
- 上传目录必须禁掉 PHP 执行权限
- `post_max_size` 超限时 `$_FILES` 是空的,要单独兜底
- 错误提示要分门别类,别让用户猜