上传键生成规则:年月/日/随机hash 为什么能防目录遍历

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP星耀SVIP管理员管理员 黑卡会员黑卡会员
发布于 2026-10-01 20:00 ·9 浏览 ·0 回复

学完这篇你能拿到一套可直接抄的上传键生成函数,并弄明白「年月/日/随机hash」这套命名规则到底是靠哪几层设计堵死目录遍历的。

第一步:先分清「原始文件名」和「存储键」

用户上传时浏览器会带一个 `filename="../../../etc/passwd"` 或 `filename="shell.php"`。这个值属于用户输入,永远不可信。

正确做法是:原始文件名只用来取扩展名(还要过白名单),真正落盘的位置由服务端自己算出来,这个算出来的字符串就是上传键。

原始文件名:我的照片.jpg
生成的上传键:202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg
落盘路径:/www/site/uploads/202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg
访问 URL:https://example.com/uploads/202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg

第二步:写生成函数,三段结构各管一件事

function uploadKey(string $originalName): string {
    $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    // 白名单:不在名单里直接拒绝,不做「替换危险字符」这种黑名单
    $allow = ['jpg','jpeg','png','gif','webp'];
    if (!in_array($ext, $allow, true)) {
        throw new RuntimeException('不允许的扩展名');
    }

    $rand = bin2hex(random_bytes(16)); // 32 位十六进制,字符集仅 0-9a-f
    return date('Ym') . '/' . date('d') . '/' . $rand . '.' . $ext;
}
  • 年月 + 日:目录分片。一个目录塞几十万文件,`ls`、备份、rsync 都会明显变慢,MySQL 无关,纯文件系统问题。按天切割后单目录文件数可控,同时天然按时间可归档、可清理。
  • 随机 hash:文件名不可猜、不重复。`random_bytes` 是密码学安全随机源;`bin2hex` 输出只含 `0-9a-f`,不含点、斜杠、反斜杠、冒号,从字符集上就不可能拼出 `..` 或 `../`。

注意:别用 `rand()`、`mt_rand()`、`uniqid()`、`md5(time())` 当随机源。这些都可预测或可碰撞,攻击者猜到别人附件的键就能越权下载。

第三步:为什么这套规则能防目录遍历

关键不在于「过滤了 `../`」,而在于用户输入根本没参与路径拼接。

  1. 目录部分 全部来自 `date()`,字符集只有数字和 `/`,用户控制不了。
  2. 文件名部分 来自 `bin2hex(random_bytes())`,字符集只有 `0-9a-f`,不可能出现 `..`、`/`、`\`、`\0`。
  3. 扩展名 走白名单,`php`、`phtml`、`php5`、`user.ini` 这些根本进不了名单。

三层叠加后,最终拼接出来的字符串里不可能包含任何能够让路径上跳的片段。这比「把 `../` 替换成空」强得多——黑名单永远有绕过方式:`....//`、URL 编码 `%2e%2e%2f`、双重编码、宽字节、Windows 短文件名、NTFS 数据流、Unicode 归一化差异,每一种都能打穿基于替换的过滤。

第四步:落盘前再加一道 realpath 兜底

即便生成规则已经足够严,写文件前仍建议校验最终路径没跑出 uploads 根目录:

$base   = realpath(__DIR__ . '/uploads');          // 必须是绝对路径
$target = $base . '/' . uploadKey($name);
$dir    = realpath(dirname($target));

if ($dir === false) {
    mkdir(dirname($target), 0755, true);           // 递归建目录
    $dir = realpath(dirname($target));
}
if ($dir === false || strpos($dir, $base) !== 0) {
    throw new RuntimeException('路径越界,已拒绝');
}

move_uploaded_file($_FILES['file']['tmp_name'], $target);

`strpos($dir, $base) !== 0` 判断的是「最终目录是否以 uploads 根开头」,配合 `realpath` 解掉符号链接和 `..`,是最后一道保险。

第五步:目录层再断一刀,别让上传目录能执行

路径安全了,还要防止「传上去的图片其实能被当脚本执行」:

location ^~ /uploads/ {
    location ~ \.(php|php5|phtml|phar)$ { deny all; }
}

Apache 则在 `uploads/.htaccess` 写 `php_flag engine off` 或 `Require all denied` 搭配静态放行。同时图片要做二次校验,用 `getimagesize()` 或 `exif_imagetype()` 确认真的是图片,防止改后缀绕过。

注意:`date()` 依赖时区。上线前在入口文件设 `date_default_timezone_set('Asia/Shanghai')`,否则跨零点归档目录会和运维预期错开一天。

第六步:在 Clara BBS 上对应检查

  • 后台「系统设置→上传」里有两个互相独立的字段:「允许的扩展名」管图片,「附件允许的扩展名」管附件,改图片白名单不会影响附件,反之亦然。
  • `uploads` 目录必须可写,否则会报「目录不可写」。
  • PHP 的 `upload_max_filesize`、`post_max_size` 要大于实际文件体积(宝塔默认 2M,手机照片常超,建议调到 30M 以上)。
  • 上传失败时页面会直接给出原因(超 PHP 上限 / 白名单不符 / 目录不可写),按提示改即可,不用瞎猜。

注意:如果一天内上传量特别大(比如几十万张),按天分片仍然会让单目录文件过多,可以在日后面再多切一层,例如用 hash 前两位:`202506/07/9f/9f2c...jpg`。

小结

  • 上传键 =「年月/日」目录 + 随机 hash 文件名 + 白名单扩展名,三段各自负责分片、唯一、合法。
  • 防目录遍历的核心是用户输入不参与路径拼接,而不是事后过滤 `../`;黑名单总有绕过方式。
  • 随机源必须用 `random_bytes`/`bin2hex`,不要用 `rand()`、`uniqid()`、`md5(time())`。
  • 落盘前用 `realpath` + 前缀校验兜底,目录层再禁掉脚本执行,图片做二次校验。
  • 时区要显式设置,上传白名单与 PHP 上传上限是两回事,报错先看具体提示。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-671.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~