Nginx 目录索引与敏感文件泄露防护配置实战

目录索引(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 输出,出问题时能在两分钟内回滚。