很多站长都遇到过这样的截图:随手把 https://www.example.com/uploads/ 敲进浏览器地址栏,结果出来一个整齐的文件列表,里面是过去几年上传的所有图片、压缩包,甚至数据库备份文件。更糟的是,Google 早就把这个列表页收录了,连带着把里面每一个文件都爬了一遍。
这个问题在小站上出现的频率高得惊人,原因是它从来不会「报错」——网页、图片、CSS 全部正常,只有当你恰好访问某个没有首页文件的目录时,才会看到这个列表。而且它有可能已经存在了两三年,直到有人告诉你。这篇文章讲的是如何把「目录可被枚举」这件事彻底关掉,同时不破坏正常访问。内容覆盖 Nginx 的 autoindex 开关、常见主题/Docker 默认配置的坑、关闭之后的验证方式,以及关闭时最容易误伤的几种场景。
一、自动目录列表是怎么出来的
Nginx 有一个指令叫 autoindex,默认值是 off。当请求的 URI 对应磁盘上的一个目录、而该目录里没有 index 指定的首页文件(比如 index.html、index.php)时,Nginx 的行为取决于这个值:off 会返回 403 Forbidden;on 会生成一个目录列表页返回给浏览器。
所以如果你看到了目录列表,说明三件事同时成立:第一,这个 URI 指向的是一个真实目录;第二,这个目录里没有 index 文件;第三,autoindex on 在某个层级被打开了。
关键在于第三点:autoindex on 可能不是你亲手写的。以下这几种情况都会悄悄打开它。
- 主题或建站程序自带的 Nginx 配置模板。很多一键部署脚本为了让「附件目录可浏览」,直接在模板里加了
autoindex on。 - 开发时为了调试方便加在
server块里,上线时忘了删。 - Docker 镜像自带的默认配置,尤其是那些基于
nginx:alpine又修改过default.conf的镜像。 alias或root写错,导致请求落到了一个本来没打算暴露的目录上,而该目录的上层恰好开了 autoindex。
排查的第一步不是改配置,而是找出「谁开的」。
# 1) 先确认现象
curl -s -o /dev/null -w "code=%{http_code} type=%{content_type}" https://www.example.com/uploads/
# 2) 找出所有打开 autoindex 的地方(含被 include 的片段)
nginx -T 2>/dev/null | grep -n "autoindex"
# 3) 看主配置的 include 关系,定位是哪个文件
nginx -T 2>/dev/null | grep -nE "include|server_name"nginx -T 会把主配置和所有 include 的文件合并后输出,是排查这类「配置不知道写在哪」问题最快的手段。注意它要求配置能通过语法检查,如果配置本身有错,先 nginx -t 修好。
二、正确的关闭方式:全局关、目录层兜底
最稳妥的做法是两层:在 http 层写 autoindex off; 作为全局默认,再在需要的地方显式覆盖。因为在 Nginx 的配置继承规则里,子层会覆盖父层,所以只要有人在下层写了 on,全局的 off 就失效了。因此第二层要落在「上传目录」这种真正敏感的位置。
http {
autoindex off; # 全局默认关闭
...
}
server {
listen 443 ssl http2;
server_name www.example.com;
root /home/wwwroot/www.example.com;
# 上传目录:不仅关列表,还要禁止执行脚本
location ^~ /uploads/ {
autoindex off;
location ~* \.(php|phtml|php5|phar)$ {
deny all; # 即使有人上传了 .php 也不执行
}
}
}几个细节值得展开。
^~ 修饰符的意思是:匹配到这个前缀后,不再尝试正则 location。这一点很关键。/uploads/ 下通常会有 location ~ \.php$ 这类正则规则,如果不用 ^~,正则的优先级高于普通前缀匹配,上传目录里的 PHP 文件仍然会被交给 PHP-FPM 执行。用了 ^~ 之后,整个 /uploads/ 前缀都会进这个块,正则被跳过,于是内层的 deny all 才真正生效。
如果站点有多个域名或多个 server 块,建议把这段放在一个单独的片段文件里,用 include 引入。这样不会出现「这个站关了那个站没关」的情况。
# /etc/nginx/snippets/no-autoindex.conf
autoindex off;
add_header X-Robots-Tag "noindex, nofollow" always;顺手加一个 X-Robots-Tag 响应头,作用是告诉搜索引擎「不要索引这个响应」。它比 robots.txt 更精确,因为它作用在响应层面,即使目录列表页已经生成,搜索引擎也大概率不会收录。但要注意:它是补救而不是预防,真正的预防还是把 autoindex 关掉。
三、验证:三种检查,缺一不可
改完配置并 nginx -s reload 之后,不能只访问一次首页就算通过。要按下面三类分别验证。
第一类是目录本身。直接访问目录 URL,期望返回 403 而不是 200。
for d in /uploads/ /uploads/2024/ /backup/ /static/; do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://www.example.com$d")
echo "$d -> $code"
done注意末尾的斜杠。带斜杠和不带斜杠在 Nginx 里可能落到不同的 location,只有带斜杠才是「目录请求」,这也是为什么很多人改了配置却发现原 URL 还能看到列表——他们测的是不带斜杠的版本。
第二类是目录里的真实文件。关闭 autoindex 不应该影响单文件访问,否则你的图片就全挂了。
curl -s -o /dev/null -w "%{http_code} %{content_type}" https://www.example.com/uploads/2024/03/photo.jpg期望是 200 image/jpeg。如果是 403,说明你的 deny all 写得范围过大,把静态文件也拦了。
第三类是之前被收录的列表页。这个要在搜索引擎里查:site:www.example.com inurl:uploads。如果发现已经被收录,处理方式是在 Search Console 里提交「移除网址」请求,并在该目录返回 403 或 404 后等待重新抓取。注意 403 不会立刻从索引消失,通常需要几周。
四、关闭之后最容易误伤的四类场景
把这块做对不难,难的是不把正常功能一起关掉。下面四种情况我都真实踩过。
第一种:插件依赖目录列表来「发现」文件。一些老式的相册或文件管理器主题会用目录列表当作「文件浏览器」,关闭后前台显示空白。判断方法是看这个目录是否被 PHP 脚本读取——查看 scan_dir、opendir 之类的调用。如果确实依赖,正确的做法不是重新打开 autoindex,而是给这个目录加一个极简的 index.php,由它来生成受控的列表。这样既保留了功能,又不暴露整个目录。
第二种:静态站点的某些路径忘记放 index 文件。如果你用 Hugo 或纯静态 HTML,某些分类页可能是 /tags/linux/index.html,但如果某个 tag 的目录是空的,访问 /tags/linux/ 就会 403。这在搜索结果里表现为「网站有很多 403 页面」,影响收录。解决方式是在构建时保证每个目录都有 index 文件,或者用 try_files 兜底。
location / {
try_files $uri $uri/ /index.html;
}注意 try_files 的 $uri/ 这一项:如果目录存在但没有 index 文件,它会继续往后找,最终落到 /index.html。这样就不会出现 403,也不会出现目录列表。
第三种:对象存储的静态站点托管。如果你把前端资源放在对象存储上,并把存储桶设成「静态网站托管」模式,那么目录列表是存储桶自己的行为,Nginx 的配置管不到。需要在存储桶的配置里单独关闭「允许目录列表」,或者通过 CDN 层加一条规则:当 URL 以 / 结尾时返回 404。
第四种:备份目录没在 Web 根目录之外。这是最严重的。有些站长把 backup/ 放在网站根目录下,靠 autoindex 关闭来「保护」它。这非常危险——一旦哪天配置被回滚或者新机器上忘了配,整个备份就被公开了。正确做法是把备份目录移到 Web 根目录之外,或者至少在 Nginx 里写一条 location ^~ /backup/ { deny all; },让它在任何 autoindex 状态下都不可访问。
五、顺带处理:那些「本来就不该被访问」的路径
目录枚举只是信息泄露的一种。和它同类的问题还有一批,建议一次性收口,因为排查逻辑是一样的:找出不该被公开的路径,用一个 location 块拒掉。
# 常见敏感路径统一拒绝
location ~ /\.(?!well-known) {
deny all; # .git .env .htaccess 等隐藏文件
}
location ^~ /backup/ { deny all; }
location ^~ /logs/ { deny all; }
location ^~ /sql/ { deny all; }
location ^~ /config/ { deny all; }第一条正则里的 (?!well-known) 是负向断言,作用是放行 /.well-known/acme-challenge/。这一条不能漏,否则 Let's Encrypt 的 HTTP-01 验证会失败,证书续期会静默挂掉——而且报错信息非常含糊,只看到一条「验证失败」,很难联想到是 Nginx 拦了隐藏路径。
另外,不要用 robots.txt 来「保护」这些路径。robots.txt 只是建议,任何爬虫都可以无视;而且 Disallow 一个路径反而可能让它被更多人所知,因为 robots.txt 本身是公开的。robots.txt 的用途是「减少无意义的抓取」,不是「保护数据」。
六、一个容易被忽略的源头:目录名本身泄露信息
即使关掉了目录列表,目录名和文件名本身也可能泄露信息。比如 /uploads/2024/11/backup-20241109.sql.gz 这样的路径,只要被猜中就能直接下载。很多人以为文件名够长就安全,实际上这类命名模式完全可枚举。
判断是否存在这种风险,用一条命令扫一遍 access log,找出被请求但实际不存在的路径(404),按路径的「目录层级」聚合。如果某个目录下集中出现大量 404,说明有人在按模式扫描。
awk '$9 == 404 {split($7, p, "/"); print "/" p[2] "/" p[3]}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20对于确实需要保护的下载文件,正确做法不是靠文件名隐蔽,而是用后端做鉴权后返回 302 跳到带签名与有效期的临时 URL。这样即使 URL 被泄露,过期后就失效了。
七、纳入例行检查
这类问题之所以能存在几年,是因为它不是「故障」,不会触发任何告警。所以必须把它从「一次性排查」变成「例行检查」。我的做法是在每月的运维检查里跑一条脚本,把关键的几个目录全部请求一遍,任何返回 200 的目录都列出来。
#!/bin/sh
# /root/bin/check_dirs.sh —— 每月跑一次,输出所有可列举的目录
HOST="https://www.example.com"
for d in uploads backup logs sql config static assets; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/$d/")
[ "$code" = "200" ] && echo "WARN $d 可列举 (200)"
done
echo "check done"把它放进 cron 每月一号跑一次,输出通过邮件或 IM 机器人推给自己。脚本本身只有十行,但能防住「换机器时忘记同步配置」这个最典型的回归场景。
八、小结
目录被枚举这件事,本质是「默认值被覆盖了,而且没有任何地方会提醒你」。排查的入口是 nginx -T | grep autoindex,修法是全局关加目录层兜底,验证必须区分「目录请求」与「单文件请求」两类,收口时要顺手放行 /.well-known/,最后把它变成一条月度检查脚本。做完整套动作大约二十分钟,但它能避免的是「几年的上传内容包括备份文件被完整下载」这种真正带来损失的事故。对于一个自建站来说,这类「不出声的安全债」比任何一次线上故障都更值得优先处理。