PHP 文件上传与 413:从浏览器到反向代理逐层排查
本文目录
原始发布日期保留为 2018-07-10。本文于 2026-10-19 重整,替换旧版
var_dump($_FILES)、不完整上传表单和直接输出PHP_SELF的示例。上传失败不能只盯 PHP:请求可能在浏览器、CDN、Nginx、PHP-FPM 或应用校验的任一层被拒绝。
一个上传请求经过哪些边界
| 层 | 常见控制 | 超限/失败时的表现 |
|---|---|---|
| 浏览器表单 | accept、可选的 MAX_FILE_SIZE | 提示用户;用户可以绕过,不能作为安全限制 |
| CDN / 负载均衡 | 产品或网关的最大请求体 | 可能直接返回 413,PHP 完全收不到请求 |
| Nginx | client_max_body_size | 请求体超过配置时返回 413 |
| PHP 上传配置 | upload_max_filesize、post_max_size | 文件字段有上传错误,或整个 POST 字段都为空 |
| PHP 应用 | 用户权限、业务配额、格式与大小 | 应返回明确且不泄露内部路径的应用错误 |
HTTP 413 表示请求体超过某一层允许的大小。浏览器看到 413 只说明请求被拒绝,不足以判断是 CDN、Nginx 还是应用网关返回;如果 PHP 尚未收到请求,应用日志和 $_FILES 中自然找不到对应文件。
表单必须声明 multipart
文件表单使用 POST、multipart/form-data,并给文件控件设置 name。accept 和隐藏的 MAX_FILE_SIZE 只改善用户体验,不能取代服务器端检查;提交时让浏览器或 curl 自动生成 multipart boundary,不要手工拼 Content-Type。
<form method="post" action="/api/uploads" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($_SESSION['csrf_token']) ?>">
<label for="book">电子书</label>
<input
id="book"
name="file"
type="file"
accept=".epub,.pdf,.txt,.mobi,.azw3"
required
>
<button type="submit">上传</button>
</form>
accept 只是选择器提示,服务端仍要鉴权、检查 CSRF、限制账号配额与字节数,并根据真实文件内容决定是否支持。扩展名不等于文件类型;文件要采用随机存储名、受控目录和安全下载流程,参见音乐资料上传与 CRUD及表单与注册安全。
先识别 PHP 上传错误码
文件输入 name="file" 对应 $_FILES['file']。先确认结构和错误码,再做业务校验;不要把整个 $_FILES、临时路径、原始文件名或文件内容直接输出到页面/日志。
<?php
$file = $_FILES['file'] ?? null;
if (!is_array($file) || !is_int($file['error'] ?? null)) {
throw new RuntimeException('上传请求不完整。');
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
throw new RuntimeException('文件超过服务器允许的大小。');
case UPLOAD_ERR_PARTIAL:
throw new RuntimeException('文件没有完整传输,请重试。');
case UPLOAD_ERR_NO_FILE:
throw new RuntimeException('请选择一个文件。');
case UPLOAD_ERR_NO_TMP_DIR:
case UPLOAD_ERR_CANT_WRITE:
case UPLOAD_ERR_EXTENSION:
default:
throw new RuntimeException('服务器暂时无法接收该文件。');
}
?>
应用还应验证 size 是正整数且不高于业务上限,确认临时文件由 PHP 上传,再用 Fileinfo 和内容解析检查类型;成功后才移动到随机命名的受控目录。不要信任 $_FILES['file']['type']、用户文件名或浏览器提供的尺寸。
upload_max_filesize 与 post_max_size
upload_max_filesize 限制单个文件;post_max_size 限制整个 POST 请求体,里面还包括其他字段与 multipart 开销。因此 post_max_size 必须大于单文件上限。如果整个请求超过 post_max_size,PHP 可能让 $_POST 和 $_FILES 都为空,应用便无法靠普通文件错误码解释问题。
假设产品允许单个文件最多 100 MiB,可把配置关系设计成类似:
; php.ini 或 PHP-FPM 实际加载的配置
upload_max_filesize = 100M
post_max_size = 110M
max_file_uploads = 5
反向代理还要允许略大于整个 POST 限额的请求体,例如相应上传路由 120 MiB。此处数字只是关系示意,必须根据文件大小、字段数、并发、磁盘空间、请求超时和机器资源设定;不要无条件配置为无限制。更改 PHP 配置后,要确认实际 FPM pool/容器读取了新设置,而不是只改到 CLI 的 php.ini。
用症状定位是哪一层拒绝
| 现象 | 优先检查 | 下一步 |
|---|---|---|
| 浏览器立刻收到 413,应用没有请求记录 | CDN、网关、Nginx body 上限 | 对照真实请求路由与返回响应头/错误日志,检查代理层配置 |
进入 PHP,但文件错误为 UPLOAD_ERR_INI_SIZE | upload_max_filesize | 按业务允许值调整,再重新读取 FPM 实际配置 |
$_POST 与 $_FILES 都是空数组 | post_max_size、上游 413、请求方法和表单 enctype | 对照请求体大小、代理日志与 PHP 错误日志;空数组也可能有其他原因 |
| 小文件也报临时目录/写入错误 | upload_tmp_dir、磁盘空间、目录权限、open_basedir | 检查 FPM 用户写权限及空间,不要把临时目录开放成公共目录 |
| 文件偶发只上传一部分 | 网络连接、客户端中断、上传超时、临时盘 | 记录上传错误码与 request ID,评估分片/断点续传 |
| PHP 返回成功,但随后无法读取 | 存储目标、权限、数据库记录、媒体路由鉴权 | 查状态记录和后台日志,不直接暴露磁盘路径 |
排查时先记录状态码、请求 ID、时间、请求体大小和响应来源;日志里不要存 Authorization、Cookie、CSRF token、图书正文或完整个人文件名。不要把服务器环境变量或配置文件内容贴进工单。
客户端请求示例
使用 curl 的 multipart 参数让客户端自己设置 boundary;请求大小要按真实文件计算,且此示例中的令牌只从本地环境变量读取,不要写入命令历史或公开日志。
curl --fail-with-body \
-H "Authorization: Bearer $UPLOAD_TOKEN" \
-F 'file=@./sample.epub' \
https://example.test/api/uploads
浏览器上传时,若用 FormData,也不要自行设置 Content-Type: multipart/form-data;浏览器需要在请求头中附带与 body 匹配的 boundary。服务端应在收到文件后尽早拒绝超限输入,但 UI 仍要显示可理解的错误和重试方式。
超大文件:不要只把上限越调越高
对大型 EPUB/音视频或网络不稳定的上传,应优先考虑分片/断点续传、可恢复的上传会话、分片校验和、完成时统一校验、配额与自动过期清理。媒体转换应进入异步队列;请求处理只负责校验并安全接收,不应长期占用 PHP-FPM worker。为每个用户和站点设定容量、并发、速率和保留策略。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。