有人跟我说某个子域名”没有 SSL 证书”。我去查,证书好好的——一张通配符证书覆盖着它,剩余有效期两个多月。
但那次排查确实查出了问题,而且比”缺证书”严重得多:那个子域名的登录页可以通过明文 HTTP 访问,页面里有密码输入框,没有任何跳转。
证书和 HTTPS 强制是两件事。这篇讲怎么把它们分开检查,以及排查过程中两个把我带偏的坑。
为什么通配符证书”看起来不存在”
误会的来源很简单:如果你在服务器上跑 certbot certificates,或者在某个面板里按域名找,通配符证书不会为每个子域名单列一条。它只有一条记录,SAN 里写着 *.example.com。
子域名越多,这个差异越容易造成误判——你有八个子域名,列表里只有一条记录,直觉上就是”七个没配”。
正确的验证方法是直接问服务器要证书,看它的 SAN 列表:
echo | openssl s_client -servername sub.example.com \
-connect sub.example.com:443 2>/dev/null \
| openssl x509 -noout -subject -dates -ext subjectAltName
关键是 -servername。现代服务器一个 IP 上跑多个站点,靠 TLS 握手里的 SNI 扩展决定发哪张证书。不带这个参数,你拿到的可能是默认站点的证书,然后得出错误结论。
我这次拿到的 SAN 是 DNS:*.example.com, DNS:example.com——一张通配符加一个裸域,覆盖所有一级子域名。那个”没有证书”的子域名一直在里面。
顺带一个通配符的边界:它只覆盖一级。*.example.com 匹配 a.example.com,但不匹配 a.b.example.com。多级子域名需要单独的证书或多级通配符。
坑一:openssl 连不出去时不要相信结论
我第一次跑上面那条命令,四个域名全部返回”握手失败”。
差一点就写成”证书全都有问题”。拦住我的是其中一个域名——那个站点我整晚都在正常访问,它不可能没有证书。
换成 curl -v 再试,四个全部正常返回证书信息。问题出在我的执行环境限制了原始 socket 连接,openssl s_client 出不去,而 curl 走的路径可以。
当一个检查工具对所有目标都报同一种失败时,先怀疑工具。真实的故障很少这么整齐。这个判断规则在后面又救了我一次。
坑二:DNS 枚举在透明代理后面完全失效
确认证书没问题后,我想把所有子域名都查一遍。想法是先探测哪些子域名存在,再逐个检查。
于是拿一份常见前缀列表去解析:www、admin、mail、api、cdn、test、git……结果每一个都解析成功了。包括我随口编的几个。
返回的地址是 198.18.0.x 这样的段。这个网段是保留给网络设备性能测试用的,正常不会出现在公网解析结果里。
解释是我的出口有透明代理在做 fake-IP:它拦截所有 DNS 查询,无论域名是否真实存在,都合成一个保留段地址返回,等实际连接时再按域名路由。在这种网络里,DNS 解析成功不代表域名存在。
改判据:不看 DNS,只看实际的 HTTPS 响应。真实存在的服务返回 200 或 302,不存在的返回码是 000(连不上)。四个真实站点因此浮出来,其余十几个”解析成功”的全是幻觉。
真正的问题
把四个站点逐个查完整之后,问题出现在一个我本来没打算查的维度上。
其中两个站点,访问 http:// 不会跳转到 https://:
http://www... 301 -> https://www... 正确
http://admin... 301 -> https://admin... 正确
http://pivot... 200 直接返回内容
http://prairie... 302 -> http://prairie/... 跳转后仍是 http
那个直接返回 200 的是 webmail。抓下来看正文:
curl -s http://pivot.example.com/ | grep -c 'type="password"'
# 1
明文 HTTP 提供的邮箱登录页,带一个密码输入框。任何人敲了 http://、或者点了一个旧链接、或者从收藏夹里打开一个协议为 http 的书签,密码就是明文上网的。
另一个站点更微妙:它会跳转,但跳到的仍然是 http 地址,最终落在一个带两个凭据输入框的二次验证页上。有跳转很容易让人以为配置正确了。
为什么会漏掉这两个
因为强制跳转在这个站点上是源站 nginx 做的,而配置只写在主站点的 server 块里。webmail 和文件服务是后来加的独立服务,各自有各自的配置,谁也没想起来补这条。
这类遗漏有个共同特征:它随着服务增加而累积,且不会有任何报错。每个服务单独看都工作正常,只有横向对比才看得出不一致。
而横向对比恰恰是最容易被跳过的一步——排查通常从”某个东西坏了”开始,注意力自然集中在那一个上面。
怎么修
如果站点在 CDN 后面,最省事的是打开 CDN 的”强制 HTTPS”开关。它在边缘生效,覆盖整个区域包括将来新增的子域名,不用碰任何源站配置,也不会有重定向循环的风险。
要在源站做也可以,但有个必须注意的点:不能用 $scheme 判断。源站在 CDN 或隧道后面时,它看到的永远是内部连接的协议,访客用的协议在 X-Forwarded-Proto 头里:
if ($http_x_forwarded_proto = "http") {
return 301 https://$host$request_uri;
}
用 $scheme 判断的后果是要么永不触发,要么无限重定向,取决于源站自己监听的是什么。
强制跳转之后,第一次请求仍然是明文的
把 HTTP 301 到 HTTPS 之后,那个明文密码框确实没了。但有一件事没解决:用户在地址栏敲域名时,浏览器默认发的仍然是 HTTP 请求,跳转发生在服务器收到它之后。也就是说每次访问的第一个请求还是明文的,中间人可以在跳转发生之前就介入。
补上这一环的是 HSTS——一个响应头,告诉浏览器「以后访问这个域名一律直接用 HTTPS,不要先试 HTTP」:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
浏览器收到之后会记住 max-age 秒。在这期间,用户即便手输 http://,浏览器也会在发出请求之前就改成 HTTPS,明文请求根本不产生。
三件必须注意的事:
- 它只在 HTTPS 响应上生效。浏览器会忽略 HTTP 响应里的这个头——否则中间人就能伪造它了。所以这个头要加在 443 的 server 块里,加在 80 的跳转块里没有任何作用。
includeSubDomains覆盖所有子域,包括还没建的。如果某个子域暂时只有 HTTP(内部工具、老服务),加上这个参数之后它会直接无法访问,而且用户端已经记住了,改回服务器配置也不能立刻恢复。上这个参数之前要确认每一个子域都已经支持 HTTPS。preload基本不可逆。提交到浏览器预载列表之后,规则会被编译进浏览器本身,撤销要等好几个版本迭代。除非你确定这个域名永远只走 HTTPS,否则不要加。
稳妥的上线顺序是:先设一个很短的 max-age=300 观察几天,确认没有任何子域被打断,再逐步提到一年。这个参数是「用户浏览器记住多久」,不是「服务器保留多久」——设错了你没法单方面撤销。
混合内容:页面是 HTTPS,里面的资源不是
还有一层是边缘跳转解决不了的。页面本身通过 HTTPS 加载了,但如果 HTML 里写死了 http:// 开头的图片、脚本或样式表,浏览器会拒绝加载它们(脚本和样式)或者标记页面为不安全(图片)。
这在 WordPress 这类内容存在数据库里的系统上尤其常见:站点早期用 HTTP 时插入的图片地址,会以绝对 URL 的形式留在文章正文里,改 nginx 配置完全碰不到它们。
排查方法是打开浏览器控制台看 Mixed Content 警告,或者直接搜内容里的明文链接:
curl -s https://example.com/ | grep -o 'http://[^"'"'"']*' | sort -u | head
需要注意的是只有指向自己站点的链接需要改成 HTTPS。指向外部站点的普通超链接(<a href="http://...">)不构成混合内容,因为它只是一个待点击的链接,不会在当前页面里被加载。要改的是 src 和 href 到样式表这类子资源引用。
顺带一个和前面那两个坑同源的提醒:改完之后用带随机参数的 URL 验证。CDN 会把旧的 HTTP 响应和旧的 301 一起缓存住,用普通地址测会看到修改前的行为,很容易得出「没生效」的错误结论。
一份最小核查清单
这次的教训是,”有没有证书”只是四个问题里的一个。对每个对外的主机名,至少要分别确认:
证书是否覆盖这个主机名。带 -servername 取证书,读 SAN 列表,别看面板里有几条记录。
http:// 是否强制跳到 https://。这一条独立于证书,而且是唯一会导致凭据明文传输的一条。查法就一行 curl -sI http://host/,看有没有 301/308 且目标是 https。
跳转的目标协议对不对。我这次就碰到 302 跳到另一个 http 地址的情况。只看”有没有跳转”会漏掉它。
有没有 HSTS。强制跳转只保护第一次请求之后的行为,首次那一次明文请求仍然发生了。HSTS 让浏览器记住”这个域名只走 HTTPS”,但它应该在强制跳转生效之后再加——顺序反了没有意义。
四条里最容易被忽略的是第二条,因为它不属于任何一个服务的”功能”,没人会在验收时测它。