
用 PHP 上传文件:处理表单、校验类型与保存路径
文件上传看起来只是一个带有“选择文件”按钮的表单,实际涉及浏览器请求格式、PHP 配置、临时文件、类型校验、文件命名和存储权限。任何一环处理不当,都可能导致上传失败,或让攻击者把可执行文件放到网站可访问的位置。
本文以 PHP 8.x 为例,从一个可运行的单文件上传流程讲起。示例允许上传 JPEG、PNG 和 PDF,限制单个文件最大为 5 MiB,并将文件保存到网站公开目录之外。它适合头像、附件、业务资料等由应用接收并管理的场景;生产系统还应结合登录权限、业务授权、配额和恶意文件检测等措施。
一、上传流程与适用场景
浏览器上传文件时,通常通过 HTTP POST 请求,以 multipart/form-data 格式同时发送普通表单字段和文件内容。PHP 收到请求后,会将文件暂存到服务器的临时目录,并在 $_FILES 中提供文件名、临时路径、大小和上传状态。应用检查这些信息后,通常调用 move_uploaded_file() 把临时文件移动到自己的存储位置。
这种方式适用于文件数量和体积可控、应用服务器能够接收请求的场合,例如用户头像、合同附件和后台资料导入。若文件很大、上传并发很高,或应用运行在多台服务器上,应考虑对象存储、分块上传、断点续传等方案;此时仍需在服务端校验对象归属、文件大小和内容,不能因为存储交给云服务就省略安全检查。
二、先写上传表单
上传表单最重要的配置是 method="post" 和 enctype="multipart/form-data"。缺少后者时,文件内容不会按预期进入 $_FILES。下面的 form.php 创建会话令牌,并把令牌放入隐藏字段,供处理脚本校验 CSRF。
<?php
declare(strict_types=1);
session_start();
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>上传文件</title>
</head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
<input
type="hidden"
name="csrf_token"
value="<?= htmlspecialchars(
$_SESSION['csrf_token'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
) ?>"
>
<label for="attachment">选择 JPEG、PNG 或 PDF 文件:</label>
<input
id="attachment"
type="file"
name="attachment"
accept=".jpg,.jpeg,.png,.pdf"
required
>
<button type="submit">上传</button>
</form>
</body>
</html>
accept 只是浏览器端的选择提示,不是安全校验。用户可以绕过页面直接构造请求,因此服务端必须独立验证文件。CSRF 令牌能帮助识别跨站伪造请求,但不能代替身份验证和授权:处理脚本仍应确认当前用户有权上传到相应业务记录。
三、PHP 上传字段和错误码
单文件字段名为 attachment 时,PHP 通常在 $_FILES['attachment'] 中提供以下信息:
name:浏览器提交的原始文件名,不应直接用于保存路径。type:浏览器提交的 MIME 声明,不可信,不能单独用于判断文件类型。tmp_name:服务器临时文件的路径,成功上传后由 PHP 管理。size:上传文件大小,单位为字节;仍应由应用自行执行大小限制。error:上传状态码,应先检查它,再使用临时文件。
常见的 error 值包括:
UPLOAD_ERR_OK:上传成功。UPLOAD_ERR_INI_SIZE:超过upload_max_filesize。UPLOAD_ERR_FORM_SIZE:超过表单中的MAX_FILE_SIZE提示值。该值由客户端提交,可被修改,不能作为可靠的安全限制。UPLOAD_ERR_PARTIAL:文件只上传了一部分,可能与连接中断有关。UPLOAD_ERR_NO_FILE:没有选择文件。UPLOAD_ERR_NO_TMP_DIR、UPLOAD_ERR_CANT_WRITE、UPLOAD_ERR_EXTENSION:分别表示临时目录缺失、写入失败或 PHP 扩展中止上传。
四、可运行的处理脚本
把下面代码保存为 upload.php,并确保它与表单脚本使用同一会话配置。上传目录通过环境变量 APP_UPLOAD_DIR 指定。请提前创建目录并让 PHP 运行用户有写入权限;示例有意不自动创建目录,避免路径配置错误时意外创建公开目录。
示例使用 Fileinfo 扩展根据文件内容判断 MIME 类型,再通过允许列表将 MIME 映射到扩展名。保存名由随机字节生成,不包含用户提交的原始文件名。部署环境需要启用 PHP 的 Fileinfo 扩展。
<?php
declare(strict_types=1);
session_start();
function fail(string $message, int $status = 400): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
fail('请通过上传表单提交文件。', 405);
}
$sessionToken = $_SESSION['csrf_token'] ?? '';
$formToken = $_POST['csrf_token'] ?? '';
if (
!is_string($sessionToken) ||
!is_string($formToken) ||
$sessionToken === '' ||
!hash_equals($sessionToken, $formToken)
) {
fail('请求校验失败,请刷新页面后重试。', 403);
}
if (
!isset($_FILES['attachment']) ||
!is_array($_FILES['attachment'])
) {
fail('没有收到上传文件。请检查 PHP 的请求大小限制。');
}
$file = $_FILES['attachment'];
if (!isset($file['error']) || !is_int($file['error'])) {
fail('上传数据格式不正确。');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
$messages = [
UPLOAD_ERR_INI_SIZE => '文件超过服务器允许的上传大小。',
UPLOAD_ERR_FORM_SIZE => '文件超过表单声明的大小。',
UPLOAD_ERR_PARTIAL => '文件没有完整上传,请重试。',
UPLOAD_ERR_NO_FILE => '请先选择文件。',
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录未配置。',
UPLOAD_ERR_CANT_WRITE => '服务器无法写入临时文件。',
UPLOAD_ERR_EXTENSION => '服务器扩展中止了文件上传。',
];
fail($messages[$file['error']] ?? '文件上传失败。');
}
if (
!isset($file['tmp_name'], $file['size']) ||
!is_string($file['tmp_name']) ||
!is_int($file['size'])
) {
fail('上传数据不完整。');
}
$maxBytes = 5 * 1024 * 1024;
if ($file['size'] <= 0 || $file['size'] > $maxBytes) {
fail('文件必须大于 0 字节且不超过 5 MiB。');
}
if (!is_uploaded_file($file['tmp_name'])) {
fail('上传来源无效。');
}
if (!class_exists(finfo::class)) {
fail('服务器未启用 Fileinfo,暂时无法校验文件类型。', 500);
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
if (!is_string($mime) || !isset($allowedTypes[$mime])) {
fail('只允许上传 JPEG、PNG 或 PDF 文件。');
}
$uploadDir = getenv('APP_UPLOAD_DIR');
if ($uploadDir === false || $uploadDir === '') {
fail('服务器未配置上传目录。', 500);
}
$uploadDir = rtrim($uploadDir, DIRECTORY_SEPARATOR);
if (!is_dir($uploadDir) || !is_writable($uploadDir)) {
fail('上传目录不存在或不可写。', 500);
}
$storedName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mime];
$destination = $uploadDir . DIRECTORY_SEPARATOR . $storedName;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
fail('服务器保存文件失败。', 500);
}
header('Content-Type: text/plain; charset=utf-8');
echo '上传成功。文件标识:' . $storedName;
这段代码没有依赖原始文件名来决定扩展名或目录,也没有把用户输入拼接进目标路径。move_uploaded_file() 会检查来源是否是 PHP 上传机制产生的临时文件;成功移动后,临时文件不再留在原位置。随机文件名降低了重名和路径穿越风险,但随机命名本身不是访问控制:如果文件仍可被公开猜测或列目录访问,敏感资料仍可能泄露。
五、大小限制要看多个位置
应用代码中的 $maxBytes 是业务层限制,但请求在进入代码前还可能被 PHP 或 Web 服务器拒绝。至少要检查以下配置:
upload_max_filesize:PHP 单个上传文件的最大大小。post_max_size:整个 POST 请求体的最大大小。它应大于单个文件限制,并预留表单字段等开销;多文件表单还要考虑所有文件的总大小。max_file_uploads:一次请求中允许处理的文件数量。max_input_time:PHP 解析输入数据的时间限制,上传慢时可能相关。- Web 服务器、反向代理或负载均衡器的请求体大小限制与超时设置。
这些参数的默认值会因 PHP 版本、发行包和部署方式而异,不能假设所有服务器都相同。可以通过服务器上的 PHP 配置检查工具或受控诊断页面确认 Web 请求实际使用的配置;命令行运行 php -i 看到的配置不一定与 PHP-FPM 或 Web 服务器相同。
如果请求体超过 post_max_size,PHP 可能无法填充预期的 $_POST 和 $_FILES。因此,脚本收到“没有上传文件”时,不一定是用户忘记选文件,也可能是请求体在更早阶段被限制。针对较大的上传,前端可提示检查服务器限制;服务端应记录诊断信息,但不宜向普通用户暴露完整服务器路径或配置细节。
六、类型校验能做什么,不能做什么
浏览器提交的扩展名、$_FILES['type'] 和 accept 属性都不能证明文件内容安全。示例使用 Fileinfo 检查临时文件的 MIME 类型,并以允许列表决定最终扩展名,这是比信任客户端声明更可靠的基本做法。
但 MIME 检测也不是对恶意内容的全面判定。不同文件可能有相似或模糊的特征;PDF 可能包含不希望接受的活动内容;图片也可能包含超出预期的内容。若业务风险较高,应使用适合该格式的解析器进一步验证,必要时对图片解码后重新编码,并接入病毒扫描或隔离流程。不要仅凭 MIME 字符串就宣称文件“绝对安全”。
SVG、HTML、脚本、可执行文件和压缩包尤其需要谨慎。它们可能在浏览器中执行内容,或携带嵌套文件。示例不接受这些格式。若产品确有需要,应明确下载响应头、访问来源、内容解析和扫描策略,而不是简单地把扩展名加进允许列表。
七、保存路径和访问方式
优先把用户文件保存到 Web 根目录之外,例如:
/srv/myapp/var/uploads
然后将环境变量设置为实际路径,例如在 PHP-FPM 或服务管理器的应用环境中配置 APP_UPLOAD_DIR。目录应由部署流程创建,所有者和权限只允许应用所需的读写操作。不要为了“省事”把整个目录设置为所有用户可写,也不要把上传目录设成脚本可执行目录。
站点目录之外的文件不能通过猜 URL 直接访问。需要下载时,应由经过身份验证和授权的下载端点读取文件,并检查当前用户是否有权访问对应记录。若使用对象存储,也应通过受控的服务端授权或短期签名链接提供访问。
如果业务必须把文件放在公开目录,需在 Web 服务器配置中禁止该目录执行脚本,并避免目录列表;下载时设置合适的 Content-Type、Content-Disposition 和安全相关响应头。具体配置取决于 Nginx、Apache 等服务器,不能只靠 PHP 代码保证目录中的文件不会被执行。
八、常见错误排查
$_FILES为空:确认表单使用 POST 和multipart/form-data,文件输入框的name与 PHP 索引一致;再检查post_max_size、表单提交方式和请求是否到达正确脚本。- 超过大小后没有具体文件错误:检查 PHP 与代理层的请求体限制。超过
post_max_size时,PHP 可能没有生成预期的上传字段。 - 返回
UPLOAD_ERR_INI_SIZE:文件超过upload_max_filesize。即使代码允许更大文件,也必须调整实际运行环境的配置。 - 保存失败:确认目录存在、PHP 运行用户有写权限、磁盘未满,并检查目录挂载或安全策略。不要用全局开放权限作为长期解决方案。
- 类型识别不一致:检查 Fileinfo 扩展是否可用,以及目标文件的实际格式。图片扩展名和内容不匹配时,应以服务端检测结果为准。
- 本地可用、线上失败:确认线上 PHP-FPM 与本地使用了相同的必要扩展、目录配置和大小限制;CLI 配置不一定等于 Web 进程配置。
九、上线前还应补上的控制
示例解决的是单文件上传的基本接收、校验和保存,不包含完整的业务安全策略。上线前至少确认以下事项:
- 仅允许已登录且有相应业务权限的用户上传,并限制谁能读取或下载文件。
- 按用户、业务对象和时间窗口设置数量或容量配额,防止磁盘被耗尽;同时监控磁盘空间和失败率。
- 对多文件上传逐个检查错误码、大小和 MIME,并另行限制文件总数与请求总大小。
- 考虑恶意软件扫描、内容解析、隔离区和审核流程,尤其是公开下载或面向其他用户分发的文件。
- 日志记录必要的请求标识、用户标识和失败原因,不记录 CSRF 令牌,也避免在日志中暴露敏感路径或不必要的文件内容信息。
- 处理成功后保存文件的业务元数据,例如随机存储标识、检测出的类型、大小、所有者和关联记录;不要把原始文件名当作唯一标识。
一个可靠的 PHP 上传流程,不是“收到文件后直接移动”,而是先确认请求格式和上传状态,再限制大小、检查文件内容、生成服务端文件名,并把文件写入受控路径。把客户端提示、PHP 配置和应用层校验分清楚,再按文件的实际用途决定访问控制与扫描策略,才能让上传功能既可用,也便于后续维护。





