下载接口只要接收用户传来的文件名,就要把路径穿越当成真实风险处理。
用户文件名 → 拼到允许目录 → 解析真实路径 → 检查目录边界 → 确认文件存在 → 输出下载内容
一、事故现场:../../.env 这种路径不能进 readfile()

很多后台下载接口一开始只是为了导出报表,代码写得很顺手:拿到参数,拼上目录,调用 readfile()。本地测几个正常文件都能下载,看起来没毛病。
真正麻烦的是,用户传进来的不一定是文件名,也可能是带 ../ 的路径片段。只要拼接后能跳出业务目录,配置文件、日志文件、临时文件都有机会被读出来。
这个接口最容易出现的现象有几类:
· 下载链接被改成 ../../.env 后仍有响应。
· 报错里出现服务器真实路径,比如 /var/www/app。
· 文件不存在时直接把 readfile() 的警告抛给前端。
排查时不要只看正常文件名,专门测一下 ../、绝对路径、URL 编码后的路径,这类问题才会露出来。
二、校验链路:用 realpath() 确认真实路径还在允许目录

realpath() 的价值在于,它会把路径里的 ../、符号链接这类东西解析成真实文件系统路径。解析完之后,再拿真实路径和允许目录做边界判断。
写法可以很短,但顺序别乱:
· 先把业务目录本身也做一次 realpath(),得到可信根目录。
· 用户传入值只当作相对文件名处理,不接受绝对路径。
· 拼接后再对目标文件调用 realpath()。
· 目标路径必须以可信根目录开头,前缀不匹配就拒绝。
$root = realpath('/storage/downloads');
$target = realpath($root . '/' . $filename);
if ($target === false || strncmp($target, $root . DIRECTORY_SEPARATOR, strlen($root) + 1) !== 0) {
http_response_code(403);
exit;
}
这里有个小坑:realpath() 对不存在的路径会返回 false。所以它更适合下载已有文件的接口。要是你的业务允许先生成文件,再下载,就要把生成动作和下载动作拆清楚。
三、参数对比:basename() 只能做显示名,不负责安全

basename() 经常被误用成安全函数。它确实能从路径字符串里取出末尾文件名,但它不检查文件是否存在,也不理解权限边界,更不确认目标文件在业务目录内。
更适合它的位置,是设置下载展示名:
header('Content-Disposition: attachment; filename="' . basename($target) . '"');
安全判断要交给 realpath()、目录边界校验、权限校验来做。把 basename() 当成防护核心,遇到符号链接、编码绕过、业务目录混乱时就很容易出问题。
还有一个兼容性细节:中文文件名下载时,不同浏览器对 Content-Disposition 的处理有差异。后台管理系统里,如果文件名不重要,可以统一给一个安全的英文名,少引入一层变量。
四、上线检查:权限、白名单、响应头都要过一遍

路径校验过了,只代表文件在允许目录里,不代表当前用户有资格下载。很多越权下载问题不是路径穿越导致的,而是文件 ID 被猜到,接口没有再查一次归属关系。
上线前我会按这个清单扫一遍:
· is_file() 通过后才输出,目录、软链异常路径不直接放行。
· 扩展名和业务类型做白名单,比如只放 .pdf、.xlsx。
· 下载前查用户权限,确认文件属于当前账号或当前项目。
· 失败时只返回业务错误,不回显真实磁盘路径。
· 大文件下载要考虑 memory_limit,不要用会把完整文件读进内存的写法。
readfile() 本身没问题,问题通常出在它前面的路径来源。下载接口看起来是小功能,但一旦放到公网后台,路径、权限、响应头这三块都得扣紧。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











