目录索引(autoindex)被打开,等于把服务器目录结构主动交给访问者;再叠加 .git、.env、.bak 这类残留文件,信息泄露往往不需要任何漏洞就能完成。本文从探测现状开始,给出 Nginx 关闭目录索引、拦截敏感文件、收敛错误页指纹的完整配置,所有片段可直接粘贴使用。
适用场景
以下情况建议完整执行本文配置,而不是只改一两行:
- 用 Nginx 直接提供图片、附件、安装包等静态资源,站点根目录与业务代码混放;
- 服务器由面板或一键镜像部署,默认模板中写死了 autoindex on;
- 站点目录里存在 .git、.svn、.env、数据库导出、压缩备份等非公开文件;
- 接入 CDN 后源站仍可被直接访问,缓存层拦不住目录直读;
- 等保测评或安全巡检要求关闭目录浏览、清理敏感文件暴露面。
前置条件
- Nginx 1.18 及以上(OpenResty 同样适用),具备 root 或 sudo;
- 站点配置文件可定位,常见路径为
/etc/nginx/conf.d/或/etc/nginx/sites-enabled/; - 已对现有配置做备份:
cp -a /etc/nginx /etc/nginx.bak.$(date +%F); - 本机可执行 curl,用于逐条验证返回码;
- 确认业务确实存在需要下载的文件类型,避免正则误伤正常附件。
原理说明
autoindex 属于 ngx_http_autoindex_module,默认值是 off,但不少面板模板会显式开启。它的触发条件很明确:请求 URI 以斜杠结尾、该目录下没有命中 index 指令指定的文件,此时 Nginx 才会生成目录列表。也就是说,关掉 autoindex 只解决了“被列出”,并不解决“被猜到”。
第二类泄露是可预测路径直读。Nginx 处理静态文件时只做 URI 到文件系统的映射,不做扩展名判断,因此 /.git/config、/.env、/db.sql、/www.zip 只要文件真实存在就返回 200。这类文件通常由部署脚本、编辑器、压缩工具顺手留下,数量远超运维预期。
第三类是 alias 路径拼接。写成 location /static { alias /data/site/static/; } 时,缺少尾部斜杠会让 URI 直接拼到目录名后面,配合 try_files 可能读到同级目录。因此防护必须分三层:关列表、拦路径、断指纹,缺一层都会留下可探测的入口。
操作步骤
第一步:探测当前暴露面
先确认问题真实存在,避免盲改。下面脚本对常见敏感路径逐个请求,只输出状态码,不下载内容:
DOMAIN="www.example.com"
for p in /.git/config /.svn/entries /.env /.DS_Store /db.sql /backup.zip \
/www.zip /.bash_history /config.php.bak /index.php~ /uploads/ /static/; do
code=$(curl -s -o /dev/null -w "%{http_code}" -m 5 "https://$DOMAIN$p")
printf "%-24s %s\n" "$p" "$code"
done
返回 200 的条目就是当前可直读的文件,返回 301 且 Location 带目录名的,通常说明目录索引可用。把结果留存为基线,改完之后逐条复测。
第二步:三层关闭目录索引
不要只在 server 块写一行,http、server、location 三层都显式声明,防止某个 location 继承到意外值:
# /etc/nginx/nginx.conf http 块
http {
autoindex off;
autoindex_exact_size off;
autoindex_localtime on;
}
server {
listen 443 ssl http2;
server_name www.example.com;
root /data/www/example;
index index.html index.php;
autoindex off;
location / {
autoindex off;
try_files $uri $uri/ =404;
}
# 下载目录单独限制:只允许列出的扩展名被访问
location /download/ {
autoindex off;
location ~* \.(zip|tar\.gz|pdf)$ {
add_header Content-Disposition "attachment";
}
}
}
关键点是 try_files 的末位必须是 =404。如果写成 try_files $uri $uri/ /index.php?$query_string,目录请求会被转到动态入口,由程序决定是否列目录,Nginx 层就失去控制权。
第三步:拦截敏感文件与备份文件
用一条不区分大小写的正则统一拦截,返回 404 而不是 403,避免暴露“这里确实有文件”:
server {
# 版本控制、环境变量、编辑器临时文件、数据库导出与压缩包
location ~* (^|/)(\.git|\.svn|\.hg|\.bzr|\.env|\.DS_Store|\.idea|\.vscode)(/|$) {
return 404;
}
location ~* \.(bak|backup|old|orig|save|swp|swo|sql|sql\.gz|tar|tar\.gz|tgz|zip|rar|7z|log|conf|ini|sh|yml|yaml)$ {
return 404;
}
# 编辑器留下的 index.php~ / .index.php.swp
location ~* ~$ {
return 404;
}
}
这段规则只对“用户可猜到的静态路径”生效,不会影响 /api/v1/report 这类交给 PHP-FPM 的动态路由,因为后者由 location ~ \.php$ 优先匹配。上线前务必确认业务没有把 .zip 作为正常下载类型;如果有,就把该目录单独用 location ^~ /download/ 提升优先级。
第四步:修正 alias 与 try_files 的路径拼接
alias 必须与 location 同尾斜杠,否则路径会被错误拼接。推荐直接改成 root 写法,路径关系更直观:
# 错误写法:location 无尾斜杠,alias 有尾斜杠,URI 会拼到 static 之后
# location /static { alias /data/site/static/; }
# 正确写法一:两者都带尾斜杠
location /static/ {
alias /data/site/static/;
autoindex off;
try_files $uri =404;
}
# 正确写法二:用 root,目录层级由 URI 决定
location /static/ {
root /data/site;
autoindex off;
try_files $uri =404;
}
# 目录穿越兜底:URI 中出现 .. 直接拒绝
if ($request_uri ~* "\.\.") {
return 400;
}
if 指令放在 server 级只做 return 是安全的,不要在其中再做 rewrite 或 proxy_pass。另外建议开启 merge_slashes on;(默认即开启),把连续斜杠归一化,减少绕过手法。
第五步:收敛错误页与服务指纹
403 和 404 的错误页会暴露 Nginx 版本与目录是否存在。统一改写成站点自身的 404,并关闭 server_tokens:
server {
server_tokens off;
error_page 403 =404 /404.html;
error_page 400 401 405 =404 /404.html;
location = /404.html {
internal;
}
}
第六步:校验并平滑生效
nginx -t && systemctl reload nginx
# 若使用 OpenResty
# openresty -t && systemctl reload openresty
reload 不会中断现有连接,但配置语法错误会导致 reload 失败而旧配置继续运行。务必先看到 syntax is ok 与 test is successful 两行再执行 reload。
第七步:定期扫描根目录残留文件
配置只能拦住已知路径,真正的源头是文件本身。建议把敏感文件扫描加进月度巡检,输出结果直接进入整改清单:
WEBROOT=/data/www/example
find "$WEBROOT" -maxdepth 4 -type f \
\( -name "*.bak" -o -name "*.sql" -o -name "*.zip" -o -name "*.tar.gz" \
-o -name ".env*" -o -name "*.swp" -o -name "*~" \) \
-printf "%TY-%Tm-%Td %10s %p\n" | sort -k3
# 版本控制目录单独统计
find "$WEBROOT" -type d \( -name ".git" -o -name ".svn" \) -prune -print
扫描结果里出现备份包的,处理方式不是改名了事,而是迁出 Web 根目录、改由带鉴权的接口分发。部署脚本里加一条排除规则(如 rsync --exclude='.git' --exclude='*.sql')能从源头避免下次再出现。压缩包命名带日期并不等于安全,www-20261101.zip 照样会被字典命中,真正有效的是让它根本不在可访问路径下。
配置验证
用同一组路径复测,期望结果如下:
/.git/config、/.env、/db.sql返回 404;/uploads/、/static/返回 404 或 403,且响应体不含文件列表;- 正常文件
/static/logo.png返回 200,Content-Type 正确; - 响应头中不再出现
Server: nginx/1.x.x的具体版本号。
# 批量复测并输出结论
for p in /.git/config /.env /db.sql /uploads/ /static/ /static/logo.png; do
read -r code ctype <<< "$(curl -s -o /dev/null -w '%{http_code} %{content_type}' -m 5 "https://www.example.com$p")"
echo "$p -> $code $ctype"
done
# 确认目录列表特征消失(返回 200 且正文含 Index of 即为失败)
curl -s "https://www.example.com/uploads/" | grep -c "Index of"
如果 grep -c 输出 0,说明目录列表特征已清除;输出大于 0 则需要回到第二步检查是否有 location 覆盖了继承值。
常见问题
Q1:autoindex off 之后目录还是能被列出,是什么原因?
通常是另一个 location 块把它打开了,或者请求被 try_files 交给了后端程序。排查顺序是:先看 nginx -T | grep -n autoindex 输出中该 server 段内是否存在 on;再确认该目录的 location 是否用了 try_files ... /index.php;最后检查目录下是否存在 index.html 之外的入口文件,被面板生成的伪静态规则接管。用 curl -I 看响应头是否带 X-Powered-By,可快速判断请求最终落到了 Nginx 还是 PHP。
Q2:加了敏感文件拦截后,后台或插件功能异常怎么办?
常见于 WordPress 备份插件、网盘程序、内部文件管理模块,它们会通过 URL 直接读取 .zip 或 .sql。处理方式不是放宽整条正则,而是把该功能目录用更高优先级的 location ^~ /wp-content/uploads/backup/ 单独放行,并给该目录加上 HTTP 基本认证或签名校验。放行范围越窄越好,同时把备份文件迁出 Web 根目录,从根源上消除直读可能。
Q3:敏感路径返回 403 和 404 哪个更安全?
优先 404。403 明确告诉扫描器“路径存在但被拒绝”,攻击者会转而尝试编码绕过或寻找同目录其他文件;404 让路径存在性不可判定,能显著降低自动化工具的命中率。只有在需要区分内部运维访问、便于日志审计时,才对固定来源 IP 返回 403,其余一律 404。
总结
目录索引与敏感文件泄露的防护不依赖某个高危漏洞,靠的是把默认配置逐项收紧。落地顺序建议固定为:探测基线、三层关闭 autoindex、正则拦截敏感后缀、修正 alias 拼接、统一错误页、批量复测。配置本身只有几十行,难点在于确认业务不受影响,因此每次调整都应保留改动前的 nginx -T 输出,出问题时能在两分钟内回滚。