staging 环境的旧价格也被引用了:测试子域名暴露与爬虫封堵的排查复盘
适用读者:负责外贸独立站日常维护的后端与运维,手上同时管着一套测试环境。如果你的 staging 域名从公网能直接打开,而最近有客户拿着 AI 回答里的旧报价来对质,这篇复盘的排查路径和封堵方案可以直接对照着做。
客户发来的截图我到现在还留着:他在 AI 助手里问自家主打砂光机的出厂价,回答给的是三年前的价格,比官网挂牌价低了将近两成,连一款两年前就停产的老型号都写得有鼻子有眼。
这是上个月 14 号下午的事。他第一反应是官网被改了,排查到半夜,官网的版本记录干干净净,一个字都没动过。第二天他找到我们这边,我先把这事儿记成了工单,编号 JIRA-4471。
引用源对上了:不是官网,是 staging
第二天上午拉电话会,先不猜原因,照着截图复现。同样的提问换几种问法各问一遍,让 AI 给出引用链接。三轮提问里有两轮的引用列表出现了 staging.mach-ex.com 下的产品页——这是他们给测试环境起的子域名,主站是 www.mach-ex.com。

打开那条引用的快照,价格表、参数表、连「2023 年季节性促销」的横幅都原样躺在那里。也就是说,AI 引擎抓的语料里混进了测试站的内容,回答时把新旧两套数据一起端了出来。
有个细节当时差点漏掉:staging 首页的 title 和主站几乎一样,正文里没有任何「测试环境勿引用」的标记。对生成式引擎来说,这两个站从内容上看不出谁正式谁临时。做生成式引擎优化(Generative Engine Optimization, GEO)时大家盯着官网内容怎么被引用,却很少有人检查测试站会不会被当成第二个官网抓走。
翻访问日志:AI 爬虫的 UA 早就来过了
接下来去翻 staging 的 Nginx 访问日志,按 UA 过滤,专门盯那几家会抓网页语料的爬虫。
结果比预想的严重。GPTBot、ClaudeBot、PerplexityBot、CCBot 这几个 UA,从去年 11 月开始就有稳定的抓取记录,频率不算高,每天几十次,但产品页和价格页全被打过。日志里最早那条是去年 11 月 6 号凌晨 3 点 17 分,抓的正好是价格表页。
简单算了一下量级:八个月里这几家爬虫合计打了 staging 一万四千多次请求,产品相关路径占了六成。测试站不是没人看,是被当作正式内容持续抓了几个月。
整个排查的动作顺序画出来是这样的:
flowchart TD
A["客户反馈 AI 报价与官网不一致"] --> B["复现提问并查看引用来源"]
B --> C["确认引用源指向 staging 页面"]
C --> D["检索访问日志里的爬虫 UA"]
D --> E["统计抓取量与起始时间"]
E --> F["部署三层封堵方案"]
F --> G["DNS 与证书层面收敛暴露面"]
排查过程整理成时间线,方便对照自己的环境检查:
| 时间点 | 事件 |
|---|---|
| 去年 11 月初 | staging 迁到新服务器,签发了公网可信证书 |
| 去年 11 月 6 日起 | AI 爬虫 UA 开始出现在 staging 访问日志 |
| 上个月 14 日 | 客户反馈 AI 报价与官网不一致 |
| 上个月 15 日 | 对比确认引用源为 staging 页面 |
| 上个月 16 日 | 三层封堵上线,同步做 DNS 与证书收敛 |
AI 爬虫凭什么找到你的测试域名:发现机制剖析
这是整件事里最值得展开的部分。很多人觉得测试域名没对外宣传过,爬虫怎么会知道。实际上爬虫拿到一个子域名的路子比想象中多。
途径一,外链。 测试链接只要被贴出去过一次——工单系统里贴给客户验收、供应商邮件里的演示链接、甚至官网页脚不小心带上——就有可能被公开页面收录。爬虫顺着链接爬,staging 页面就成了抓取队列里一个普通的 URL,和其他页面没有区别。
途径二,证书透明度日志。 这是这份复盘里最关键的机制。按 CA/浏览器论坛的 Baseline Requirements,凡签发公网可信的 TLS 证书,都必须提交到公开的证书透明度(Certificate Transparency, CT)日志。换句话说,你们给 staging 子域名签一张 Let's Encrypt 证书的那一刻,这个子域名就被永久公示了,任何人去 crt.sh 输入主域名,底下所有子域名一栏无余。爬虫运营方只需要定期拉取 CT log 的新增条目,就能批量拿到全网新出现的子域名。他们 11 月初迁服务器时新签了证书,11 月 6 号爬虫就来了,时间对得严丝合缝。
途径三,DNS 字典爆破。 staging、test、dev、uat、beta、pre 这些前缀是子域名枚举工具字典里的高频词。被动收集不够时,主动把常见前缀拼一遍再验证解析,也能把测试域名翻出来。
flowchart LR
S["AI 爬虫发现 staging 子域名"] --> P1["公开页面上的测试外链"]
S --> P2["证书透明度日志里的签发记录"]
S --> P3["DNS 字典爆破枚举常见前缀"]
P1 --> T["子域名进入抓取队列"]
P2 --> T
P3 --> T
搞清楚发现机制,封堵思路就顺了:外链收干净,证书别再进 CT log,子域名要么挡住要么换个猜不到的名字。
三层封堵:进不来、不敢抓、抓了也没用
光靠一层手段总有漏网的情况,我们上了三层,各管一段。
第一层是 HTTP Basic Auth。测试环境本来就该有访问门槛,整站加认证之后,爬虫没有凭据,请求直接吃 401。
第二层在 Nginx 的 UA 和 Host 上做拦截。主流 AI 爬虫的 UA 直接返回 403;再配一个 default_server 兜底,凡是不带正确 Host 的握手请求一律拒绝。
第三层是 robots.txt 加 noindex 双保险。有人会说前面都 403 了还要这个干嘛——要的。一是部分合规爬虫会先读 robots 再决定抓不抓,提前声明省得浪费双方流量;二是 noindex 头对已经被抓走的存量快照起清理作用,搜索引擎和部分 AI 引擎会逐步把已收录的测试页摘掉。
三层方案各自的分工和局限列一下:
| 层级 | 手段 | 挡住什么 | 局限 |
|---|---|---|---|
| 认证层 | HTTP Basic Auth | 一切无凭据访问,含全部爬虫 | 覆盖不了已被抓走的存量数据 |
| 边缘层 | Nginx UA 拦截与 Host 兜底 | 已知 AI 爬虫 UA、伪造 Host | UA 可以伪造,挡君子不挡小人 |
| 声明层 | robots.txt 与 X-Robots-Tag | 合规爬虫的未来抓取与存量收录 | 对无视声明的爬虫没有强制力 |
Nginx 配置如下(环境:Nginx 1.24,Ubuntu 22.04,htpasswd 用 apache2-utils 生成):
# 环境:Nginx 1.24,Ubuntu 22.04
# htpasswd 文件用 apache2-utils 的 htpasswd 命令生成
server {
listen 443 ssl;
# 该域名仅内网 DNS 能解析,公网已停记录
server_name staging.mach-ex.com;
# 第一层:整站 Basic Auth,无凭据一律 401
auth_basic "staging only";
auth_basic_user_file /etc/nginx/staging.htpasswd;
# 第二层:命中主流 AI 爬虫 UA 直接 403
if ($http_user_agent ~* (GPTBot|ClaudeBot|PerplexityBot|CCBot)) {
return 403;
}
# 第三层:全站响应带 noindex,清理存量收录
add_header X-Robots-Tag "noindex, nofollow" always;
}
# 兜底:其他 Host 的 TLS 握手直接拒绝
server {
listen 443 ssl default_server;
ssl_reject_handshake on;
}
封堵上线后写了个校验脚本,每次改配置跑一遍(环境:Python 3.10+,仅标准库,内网执行):
# 环境:Python 3.10+,仅标准库
# 只跑在内网机器上,不要放到公网执行
import urllib.request, urllib.error
# 伪装 AI 爬虫 UA,期望被 403 拒绝
UA_BOT = "GPTBot"
# 模拟普通浏览器,期望被 Basic Auth 挡成 401
UA_HUMAN = "Mozilla/5.0"
def probe(url, ua):
# 带自定义 UA 发请求,返回状态码和响应头
req = urllib.request.Request(url, headers={"User-Agent": ua})
try:
r = urllib.request.urlopen(req, timeout=10)
return r.status, dict(r.headers)
except urllib.error.HTTPError as e:
# 4xx 走这里,状态码照样要拿回来
return e.code, dict(e.headers)
# 校验一:AI 爬虫 UA 必须被边缘层拒绝
code, _ = probe("https://staging.mach-ex.com/", UA_BOT)
assert code == 403, f"AI 爬虫未拦截 {code}"
# 校验二:浏览器请求被 401,且带 noindex 头
code, h = probe("https://staging.mach-ex.com/", UA_HUMAN)
assert code == 401 and "noindex" in h.get("X-Robots-Tag", "")
print("三层封堵校验全部通过")
DNS 和证书层面的收敛
三层封堵解决的是「进来了怎么办」,暴露面本身还得收,这部分分四步做。
先停掉 staging 的公网 DNS 记录,改成内网解析,办公室和 VPN 用户走内部 DNS 才能解析到测试站。公网上查不到这个域名,DNS 爆破这条途径就断了。
证书这块,不再给 staging 签公网可信证书,改用内部 CA 自签,浏览器手动信任一次即可。代价是有警告提示,但测试环境能接受。不进 CT log,等于从公示名单上把名字划掉了。
防火墙加白名单,443 端口只放行办公室出口 IP 和 VPN 网段,跟 Basic Auth 双保险。
旧的 staging 域名已经进过 CT log,删不掉,那就让它废掉:解析到主站做 301,或者干脆停止解析。配合 noindex 头,存量的收录和快照在之后几周陆续清掉了。顺手把 robots.txt 也补上,虽然此时更像是一种姿态:
# staging 全站对爬虫关闭,配合 noindex 头使用
User-agent: *
Disallow: /
封堵前后的对照:
| 观测项 | 封堵前 | 封堵后 |
|---|---|---|
| 公网可达性 | 无需凭据直接打开 | 401 拦截,仅内网可访问 |
| AI 爬虫抓取 | 多家 UA 稳定抓取八个月 | UA 层 403,日志归零 |
| 证书透明度 | 子域名在 CT log 公示 | 改自签,不再新增公示记录 |
| 搜索与语料收录 | 测试页可被收录引用 | noindex 生效,存量逐步清理 |
| 报价泄露风险 | 新旧价格混入 AI 回答 | 引用源仅剩正式官网 |
复盘完想提醒的几个误区
误区一是把 robots.txt 当封堵手段。它只是单方面声明,没有强制力,无视它的爬虫照样抓。它的正确位置是和认证、拦截配合,声明「这里不该来」,而不是指望它自己挡人。
误区二是觉得测试站没做过外链就没人知道。CT log 和 DNS 爆破都不需要你主动留链接,只要签过公网证书、用过常见前缀,被翻出来是迟早的事。做完这单之后我给自己定了个习惯:新项目初始化时就去 crt.sh 搜一遍自家主域名,看看有哪些子域名已经躺在公示名单里。
往前看,GEO 这个方向被越来越多外贸团队重视,大家琢磨的是怎么让官网内容更容易被 AI 引擎引用。但 GEO 有个前提常被忽略:引用源得干净。测试站、过期落地页、废弃域名里的旧数据一样会被卷进语料,而且它们往往连基本的防护都没有,被引用的概率反而比主站高。做 GEO 之前先把自家的门关好,不然优化得再用心,AI 回答里照样可能蹦出一条三年前的价格。
你们的环境里还有多少个能从公网直接打开的测试域名?建议今天就查一遍,CT log 上搜一下自家主域名,结果可能会吓你一跳。踩过类似坑的欢迎评论区聊聊,尤其是 noindex 清理存量快照花了多久。
参考与延伸
- Google 搜索中心关于阻止索引与 X-Robots-Tag 的官方文档:https://developers.google.com/search/docs/crawling-indexing/block-indexing
- robots.txt 协议官方说明:https://developers.google.com/search/docs/crawling-indexing/robots/intro
- Nginx ngx_http_auth_basic_module 模块文档:https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html
- 证书透明度日志公开查询入口:https://crt.sh/
staging 环境、测试子域名、AI 爬虫封堵、robots.txt、Nginx 配置、外贸独立站、GEO