BLOG

PHP文件上传安全要检查哪些地方

文件扩展名、MIME、大小、保存路径和访问权限都需要逐项验证。

分类:PHP 全栈开发 阅读:198
PHP文件上传安全要检查哪些地方
PHP文件上传安全要检查哪些地方

文件上传是PHP后台开发里风险比较集中的入口。扩展名、MIME类型、文件大小、保存路径、访问权限,任何一环只做表面校验,都可能留下脚本执行或越权读取的隐患。下面按实际检查顺序说明,每一条都对应可以直接验证的逻辑。

先确认上传功能的使用边界

动手检查代码之前,先明确这个上传入口给谁用、传什么类型、传到哪个目录。同样是上传,前台用户传头像和后台管理员传安装包,安全策略完全不同。前台入口默认不可信,所有校验都要在服务端重新做一遍;后台入口虽然可信度稍高,但也要防止越权调用,不能因为页面藏在管理区就跳过参数校验。

还要确认上传目录和Web根目录的关系。如果文件保存在public/uploads下,访问路径就是直接的URL;如果保存在storage/private下,就不能让Web服务器直接访问,必须通过PHP脚本鉴权后输出。这个边界决定后续的目录权限和文件名策略。

逐项检查服务端校验逻辑

文件扩展名和MIME类型

扩展名检查不能只看$_FILES['file']['name']的后缀,这个值完全由客户端控制。更可靠的做法是结合finfo_file()读取文件真实内容,判断实际类型是否在允许列表内。允许列表用白名单,明确写出允许的扩展名和对应MIME,例如图片只接受jpg、png、gif,拒绝所有不在列表里的值。

黑名单方式不可靠,php、php5、phtml、pht这类可执行后缀很难列全,而且不同服务器对可执行后缀的解析规则不一致。白名单之外的一律拒绝,逻辑简单,误判也少。

```php
$allowed = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detected = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);

$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!isset($allowed[$ext]) || $allowed[$ext] !== $detected) {
exit('文件类型不允许');
}
```

这段逻辑同时校验了扩展名和文件内容,两者必须匹配且都在白名单内。注意$_FILES['file']['tmp_name']是PHP上传后生成的临时文件,finfo_file()读取的是临时文件内容,不是客户端声明的类型。

文件大小限制

$_FILES['file']['size']是客户端声明的字节数,PHP会按upload_max_filesize和post_max_size两个配置项先做一层限制,超过配置值的请求根本到不了业务代码。业务层还需要根据自己的业务场景设一道上限,比如图片限制2MB,压缩包限制50MB,这个值写在业务逻辑里,不依赖服务器全局配置。

检查时注意区分两个错误码:UPLOAD_ERR_INI_SIZE表示超出PHP配置限制,UPLOAD_ERR_FORM_SIZE表示超出表单MAX_FILE_SIZE隐藏字段限制。后者由客户端传入,不可信,不能作为唯一判断依据。

文件名和保存路径

原始文件名不能直接用于保存,原因有两个:一是可能包含路径信息,构造得当的../../序列在拼接目录时可能造成目录穿越;二是文件名本身可能包含特殊字符,在不同操作系统下行为不一致。服务端用uniqid()或bin2hex(random_bytes())生成新文件名,扩展名从白名单映射表里取,不拼接原始后缀。

保存路径也要检查。move_uploaded_file()的目标路径必须是服务端计算出来的绝对路径,不能包含任何来自用户输入的部分。上传目录的权限设置为Web服务器用户可写即可,不需要777。目录下禁止执行脚本,Nginx下可以在location块里关闭PHP解析,Apache下用php_admin_flag engine off或.htaccess配合RemoveHandler实现。

访问权限控制

公开文件直接放Web目录,访问时走静态文件通道。私有文件放Web目录之外,通过PHP脚本读取后输出,输出前检查会话权限。判断依据很简单:文件内容是否涉及用户隐私、商业数据或未发布内容。涉及就按私有处理,不涉及才考虑公开。

私有文件输出时还要注意响应头,Content-Disposition设为inline还是attachment取决于业务需要,同时设置X-Content-Type-Options: nosniff防止浏览器嗅探实际类型。

容易出错的位置

第一处是只校验了$_FILES数组是否存在,没校验error字段。文件上传失败时error会返回对应错误码,不检查就直接处理临时文件,可能拿到一个空文件或非法路径。

第二处是图片上传后没有重新采样。合法的图片文件可能在尾部附加PHP代码,扩展名和MIME检查都通过,但服务器如果对图片文件解析脚本,附加代码就可能被执行。用GD库重新创建图片副本,只保留像素数据,能有效清除这类附加内容。非图片文件无法用这个方法,只能靠目录禁止执行脚本兜底。

第三处是上传和后续业务操作不在一个事务里。文件先保存成功,但数据库记录写入失败,磁盘上就留下孤儿文件;反过来数据库先写入,文件保存失败,页面就出现裂图。处理顺序应该是先保存文件到临时位置,再写数据库记录,最后把文件移动到正式位置,任一步失败都回滚并清理临时文件。

验证检查结果的方法

完成修改后,按下面顺序做一轮实际验证:

先准备测试文件,包括白名单内的正常文件、改了扩展名的可执行文件、超大文件和空文件。逐个上传,确认正常文件能通过,异常文件被拒绝且错误提示清晰。

再检查上传目录,确认没有生成可执行文件。用浏览器直接访问上传文件的URL,图片能正常显示,PHP文件返回空白或下载而不是执行,说明目录禁止脚本解析生效。

最后检查权限边界。未登录状态访问私有文件URL,应该被重定向到登录页或返回403;登录状态访问自己上传的文件,能正常打开。再查看应用日志,确认每次拒绝都有记录,方便后续排查。

检查记录里写明测试了哪些文件类型、目录权限如何设置、日志里能看到什么信息。这些内容比口头描述“做过安全处理”更有说服力,也是后续维护时判断改动是否引入问题的基础。

评论

登录后可发表评论。