适用对象:在虚拟主机/共用服务器或 VPS 上运营多个站点、部署到公开目录(public/、public_html/、htdocs/ 等)的人。这里不讲攻击步骤,只讲盘点自己的服务器并把洞堵上。.env 本身的保护方法,见 共用服务器上的 .env 保护。
本站的视角:放在哪里,本身就是访问控制
密钥泄露的原因,比起高明的攻击,更多是放错位置的事故。webroot 是「任何人都能通过 URL 来取的地方」。一旦把密钥放进那里,服务器的密码再强也毫无意义。本站的基本做法是密钥既不放进 webroot 也不放进 git(→ 密钥的安全保管)。思路很简单:公开目录里只放被任何人看到也无所谓的东西。
public/ = 任何人都可通过 URL 取走
可以放
图片、CSS、JS 等公开资源
不要放
.env、token.json、.sql、.git、.bak
应用根目录之上 = 不对外提供
放在这里
密钥、令牌、认证凭据、备份。权限 600(仅所有者可读)。无法通过 URL 访问。
为什么会发生「公开目录里的遗忘」
常见的情形是:为图方便而放的文件,或样板里自带的文件,就这样一直处于公开状态。
例如,一个存有第三方集成访问令牌的 JSON 长期放在 public/ 之下,而如果它源自共用模板,同一个文件会被复制到许多站点的同一位置,它们都有同样的问题。只修好一个站点,其他站点仍留着这个文件,就毫无意义。同样危险的,还有遗留的 .env、数据库备份(.sql)、.git 目录、编辑器备份(*.bak)和快捷方式(相关:路径穿越 · .env 被整个公开的事)。
容易被遗留的文件,以及各自的危险
提到「密钥文件」,人们通常会想到 .env,但实际被遗留的范围要广得多。OWASP(专注于 Web 安全的非营利组织)的测试指南也把旧版本文件、备份以及没有任何链接指向的文件列为检查对象。
| 类型 | 典型文件名 | 面临的风险 | 应放在哪里 |
|---|---|---|---|
| 配置文件 | .env、config.php.bak、settings.json | 以明文写着的数据库密码和 API key | 应用根目录之上(不对外提供) |
| 令牌和认证凭据 | token.json、credentials.json、*.pem、*.key | 可直接登录外部服务或其他服务器 | webroot 之外,权限 600 |
| 手动或编辑器备份 | login.php.old、index.php~、.index.php.swp、*.orig | 扩展名变了,文件可能不会作为代码执行,源代码会以纯文本显示 | 不要在生产环境中创建;在本地编辑 |
| 数据库转储 | backup.sql、dump.sql.gz | 用户的个人信息和密码哈希,全在一个文件里 | 专用的备份位置,最好在服务器之外 |
| 站点压缩包 | site.zip、www.tar.gz | 包含 .env 和全部源代码 | 解压后立即删除 |
| 版本控制数据 | .git/ | 过去提交中的密钥可能被还原 | 公开目录之外 |
| 构建时嵌入的值 | assets/index-xxxx.js 之中 | 以 VITE_ 或 NEXT_PUBLIC_ 开头的变量会进入浏览器端的 JavaScript | 密钥只放在服务端变量中 |
最后一行很容易被忽略。即使你从未放过文件,构建也可能把密钥推进公开目录。Vite 的官方文档明确指出,VITE_ 变量不得包含 API key 等敏感信息。
盘点自己的服务器
首先,机械地找出「自己的公开目录里有没有看起来像密钥的文件」。这是检查自己的资产,而不是窥探别人的站点。
在公开目录之下搜索看似密钥的文件
public/ 的密钥候选(令牌/认证凭据的 JSON、.env、密钥、快捷方式)。假定其他地方也有同一文件,检查所有主机和站点
逐个判断是否真的需要公开
# audit under your own public directories (inspecting your own assets)
find ~ -path '*/public/*' \( -iname '*token*.json' -o -iname '*credential*.json' \
-o -iname '*.bak' -o -iname '*.sql' -o -iname '.env*' \) 2>/dev/null
# an exposed .git directory is dangerous too
find ~ -path '*/public/*/.git' -maxdepth 8 2>/dev/null如何检查自己的环境
上面的 find 只是起点。按下面五项逐一检查,能发现仅靠文件名搜索会漏掉的遗留文件。只对自己的服务器和自己的域名执行。.env 特有的检查(查看响应的开头字节、确认 .htaccess 确实生效)见 共用服务器上的 .env 保护。
也搜索备份和压缩包
公开目录的名称因主机而异,所以要包括 public_html、htdocs 和 www。例如运行 find ~/public_html \( -name '*~' -o -name '*.swp' -o -name '*.old' -o -name '*.orig' -o -name '*.zip' -o -name '*.tar.gz' -o -name '*.pem' -o -name '*.key' \) 2>/dev/null,逐个确认每个结果是否真的应该公开。
确认目录列表已关闭
在浏览器中打开一个没有 index.html 的目录(例如 /uploads/)。如果看到文件名列表,说明 Web 服务器的目录列表功能是开着的。有了列表,别人不知道文件名也能发现遗留文件。返回 403 或 404 才是正确的。
在构建出的 JavaScript 中搜索密钥
如果你构建前端,就搜索要部署的输出文件夹(dist/、.next/static/ 等)。可以用 grep -rlE 'BEGIN [A-Z ]*PRIVATE KEY|secret|password' dist/ 之类的命令,再加上你所用服务的 key 前缀。如果找到,检查该变量是否带有面向浏览器的名称(VITE_、NEXT_PUBLIC_)。
确认 webroot 之外的密钥不能被其他用户读取
把密钥移出 webroot,如果权限宽松、同一服务器上的其他账户能读取,也没有用。find ~ -maxdepth 3 \( -name '.env*' -o -name '*.pem' -o -name '*token*.json' \) -perm /o+r 2>/dev/null 会列出所有者以外的人可读的密钥;用 chmod 600 修正。没有输出就说明没问题。
检查搜索引擎是否收录了什么
在 Google 中搜索 site:你的域名,查找不认识的文件(.sql、.zip、.txt 等)。如果你使用 Search Console,「Pages」(网页)报告会列出已编入索引的 URL。如果仍留在搜索结果里该怎么办,见下面的常见错误。
发现时的处理
把移除、吊销、迁移三件事成套执行。缺少任何一件,处理都不完整。
从 webroot 移除,使其无法通过 URL 取走
吊销或重新签发可能已泄露的密钥或令牌
把密钥放到 webroot 之外,权限 600
按服务器:拒绝提供这些文件的设置(作为兜底)
真正的修复是不把文件放在那里。在此之上,在 Web 服务器上加一条拒绝规则,即使误放了也不会被提供出去。能用什么取决于你的环境。
| 环境 | 拒绝方法 | 目录列表 | 注意点 |
|---|---|---|---|
| Apache(VPS) | 在 <Directory> 块或 .htaccess 中使用 <FilesMatch> + Require all denied | 用 Options -Indexes 关闭 | Apache 2.4 中 AllowOverride 默认为 None,除非允许,否则 .htaccess 会被忽略 |
| Nginx(VPS) | 在 location 中写 deny all; | autoindex 默认为 off | 不读取 .htaccess。正则 location 块按书写顺序检查,第一个匹配的生效 |
| 共用服务器 | 通常用 .htaccess(兼容 Apache) | 通过控制面板或 .htaccess | 用一个不含任何密钥的测试文件确认它是否生效 |
| 静态托管 / CDN | 输出文件夹里的内容会原样发布 | 取决于服务 | 依靠让密钥不进入输出文件夹,而不是依靠拒绝规则 |
Apache 示例(.htaccess 已启用的情况):
Options -Indexes
<FilesMatch "(^\.|~$|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key)$)">
Require all denied
</FilesMatch>Nginx 示例(在 server 块内):
# Allow the path used for automatic certificate renewal (Let's Encrypt) first
location ^~ /.well-known/acme-challenge/ { }
# Deny files and directories starting with a dot (.env, .git, ...)
location ~ /\. { deny all; }
# Deny backups and archives
location ~* (~|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key))$ { deny all; }在 Nginx 中,当请求匹配到标有 ^~ 的 location 时,不会再检查正则 location。这样,尽管有拒绝以点开头路径的规则,用于证书续期的 /.well-known/acme-challenge/ 仍然可以访问。修改配置后,用 nginx -t 检查语法,重新加载,并确认测试文件返回 403。如果你的站点提供 ZIP 等文件下载,就把该扩展名从拒绝列表中去掉。
容易落入的放置方式(危险)
- 集成用的令牌 JSON 放在
public/中 - 样板里自带的密钥文件一直公开着
- 备份(
.sql/.bak)或.git留在公开目录之下 - 只修一个站点就当作「处理完毕」
正确的放置方式
- 公开目录里只放可以公开的东西
- 密钥放在 webroot 之外,权限 600
- 敏感扩展名拒绝提供
- 发现一处就排查所有主机和站点
把「只放可以公开的东西」定为常规规则
规则胜过一次性的修补。定下公开目录只放可以公开的文件,原则上绝不在那里放密钥、备份、版本控制数据(.git)或配置文件。更新样板时,要从模板本身去掉密钥,否则之后批量生产的每个站点都会继续复制同一个密钥文件。
常见错误及修正方法
- 依赖拒绝规则,把文件留在原处:迁移服务器或重写配置时规则可能丢失,从那天起文件就公开了。Nginx 根本不读取
.htaccess。把规则当作兜底,把文件移出公开目录。 - 直接在生产服务器上编辑文件:编辑器会留下以
~或.swp结尾的备份。OWASP 测试指南也建议不要在 Web 服务器上直接编辑文件。在本地编辑,通过部署发布变更。 - 把整个项目文件夹复制到公开目录:
.env、.git和测试数据会一起出去。只提供构建输出文件夹,并明确指定复制哪些内容。 - 拒绝所有以点开头的路径,导致证书续期失败:Let's Encrypt 会把验证文件放在
/.well-known/acme-challenge/下。像上面的 Nginx 示例那样,先允许这个路径。 - 把密钥放进
VITE_或NEXT_PUBLIC_变量:它们会被嵌入构建出的 JavaScript,任何人都能读到。只在服务端代码中使用密钥,并吊销已经以这种方式发布出去的 key。 - 删掉文件就到此为止:如果搜索引擎已收录,它可能还留在结果里。确认 URL 返回 404 或 410,然后在等待重新抓取清除它的期间,用 Google Search Console 的「Removals」(移除)工具暂时隐藏(约六个月)。
真实案例:公开报告中隐藏的个人数据
负责华盛顿特区 Medicaid(面向低收入人群的公共医疗保险)的卫生保健财务部(DHCF)表示,从 2023 年到 2026 年 7 月发布在其网站上的两份统计报告,以「隐藏字段」的形式包含了底层的个人数据。这些报告本来只显示汇总数字,但文件中仍保留着受益人的 Medicaid 编号、出生日期等。其向美国卫生与公众服务部(HHS)提交的报告中,受影响人数为 399,086 人。DHCF 于 2026 年 7 月 21 日获知此事,并撤下了这些报告。
这不是遗忘密钥文件的案例,但根源相同:
- 公开目录中的文件可以被完整取走,而不只是画面上显示的部分
- 发布者以为「看不见」的信息,在审核中最容易被遗漏
- 可能放了好几年才有人察觉
在发布前加一个检查「这个文件里实际有什么」的步骤,既能防止遗留文件,也能防止隐藏数据。完整经过见 华盛顿特区 Medicaid 数据暴露事件(英文)。
本站是怎么做的
本站的基本做法是把密钥(key、令牌、连接字符串)既不放进公开目录,也不放进代码仓库。部署产物只包含构建好的公开资源;密钥作为运行环境的环境变量,保存在不对外提供的地方。原因是:本文这一类事故,正是「一个遗留文件就让任何请求其 URL 的人拿走全部」的典型。我们把放置位置本身设计成访问控制,并以与 漏洞应对的盘点 相同的周期执行这项检查。
接下来阅读
- 按技术栈:共用服务器上的 .env 保护 / 漏洞应对实务(盘点)
- 术语:.env 文件与密钥信息 / 路径穿越
- 事故:.env 被整个公开的事
- 保管:密钥的安全保管
- 云上:公开的存储桶(服务器之外的同类问题)
- 服务商被攻破时:虚拟主机服务商被攻破时该怎么办(假定放在那里的东西已被读取)
- 真实案例:公开报告中残留的 Medicaid 编号(华盛顿特区)(英文;公开文件被完整暴露)
参考资料
- IPA,"20 Checkpoints for Secure Website Operation and Management"(日文;不应公开的文件、搜索引擎缓存):ipa.go.jp
- OWASP, "Web Security Testing Guide: Review Old Backup and Unreferenced Files for Sensitive Information"(.old、~、.swp 备份;不要在服务器上直接编辑):wstg.owasp.org
- Apache HTTP Server 2.4, "Upgrading to 2.4 from 2.2"(
AllowOverride默认为None)和 "mod_autoindex"(Options +Indexes会生成列表):httpd.apache.org, httpd.apache.org - nginx, "ngx_http_core_module: location"(匹配顺序与
^~)和 "ngx_http_autoindex_module"(autoindex默认为off):nginx.org, nginx.org - Let's Encrypt, "Challenge Types"(HTTP-01 使用
/.well-known/acme-challenge/):letsencrypt.org - Vite, "Env Variables and Modes"(
VITE_变量会暴露给客户端代码):vite.dev - Next.js, "Environment Variables"(
NEXT_PUBLIC_变量会内联到浏览器端 JavaScript 中):nextjs.org - Google Search Console 帮助, "Removals tool"(临时移除约持续六个月):support.google.com
- District of Columbia DHCF, "Notice of Data Incident":dhcf.dc.gov;HIPAA Journal(向 HHS 报告的人数):hipaajournal.com