web-security 系列把安全设置当作带作用域与失败模式的契约。本教程以只读方式、端到端地对一个线上主机跑这些契约,并告诉你每个答案的含义。它不改变服务器,只检查服务器愿意对任何人说的话。下面全程用 www.xbcatm.com,请换成你自己的主机。

1. 从边缘宣告的头部开始

第一个契约是浏览器被告知允许什么。用 check-http-headers.sh(或任何只读抓取)取头部,读实际策略:

$ curl -s -D - -o /dev/null https://www.xbcatm.com/
HTTP/2 200
content-security-policy: default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; style-src 'self'; img-src 'self' data:
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY

每个头部都是对特定失败模式的声明。对静态站而言带 script-src 'none' 的 content-security-policy 是最强的,security-headers 说明为什么策略应匹配实际页面。带 includeSubDomains 的 strict-transport-security 是传输承诺,hsts-rollout 警告它必须分步推进,因为它具有粘性。nosniff 阻止 MIME 类型猜测。你以为讲的是真的却没有验证过的头部,是 bug 不是特性。

第一步是静态的,但它是浏览器最先看到的契约。对静态站,script-src 'none' 既安全又诚实:没有可执行的,就什么都不运行。重要的习惯是把你相信的策略与边缘实际发送的对比。代理增加或去除某个头部就是静默改变契约;只读 fetch 能抓住这种漂移。若你用 check-http-headers.sh,它把状态与头部一起读,让策略与可达性声明留在同一条记录里。

2. 确认身份是真的,而非猜测

TLS 身份始于叶证书。读它确切的主体、签发者与有效期:

$ echo | openssl s_client -connect www.xbcatm.com:443 -servername www.xbcatm.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN=www.xbcatm.com
issuer=C=US, O=Let's Encrypt, CN=YE1
notBefore=Aug 21 08:37:13 2026 GMT
notAfter=Nov 19 08:37:12 2026 GMT

主体是确切主机名而不是相邻站点的通配符,notAfter 是具体日期。certificate-expiry 把那个日期当作发布关卡而不是仪表盘颜色。操作者应能凭上次审计的记忆报出 notAfter,而不是在续期时才发觉。

3. 读链,而不只是叶

叶只有处在客户端能走到受信根的链中才有价值。问边缘实际呈现哪条链:

$ echo | openssl s_client -connect www.xbcatm.com:443 -servername www.xbcatm.com -showcerts 2>/dev/null | grep 'BEGIN CERT' | wc -l
4

带其叶与中间证书时,这个数应为 4。certificate-chain 笔记解释原因:叶必须由你实际授权的中间签发,边缘必须发送那个中间,深度验证的客户端才会成功。对公共 CA 而言,恰好为 1 是危险信号。

第二个要检查链的原因:第 2 步读到的身份只证明叶,不证明客户端能到达它信任的根。链是补上那个缺口的环节。如果边缘某天开始发送不同的中间证书——比如某天一个测试 CA——issuer 与数量会在页面上任何元素变化之前改变。读链是发现信任发生迁移的最廉价方式。

4. 用一个验证器证明新鲜度完好

conditional-requests 笔记在这里同样适用:活动边缘应对重新验证回 304,这样缓存与客户端不必重新下载。这也是缓存按头部承诺工作起来的证据:

$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -H 'If-None-Match: "dkx84jwth7nkc9e"' https://www.xbcatm.com/
HTTP 304

因为本站坚持无脚本策略,CSP 在发布之间从不变化;只有字节变化时 etag 才动。

5. 检查哪个 CA 被允许签发

TLS 身份与 DNS 策略携手。确认 CAA 记录授权你实际使用的签发者,这样只有那个 CA 能合法为域名签名:

$ dig +short CAA www.xbcatm.com

dns-caa-records 把 CAA 当作信任的供应商:它授权签发者,使证书链审计变得有意义。若无 CAA,任何 CA 都能签发;有 CAA,第 3 步的中间 应该能追溯到被授权的签发者。若 dig 返回空,意味着没有任何 CA 被限制——这本身是审计结论。

6. 检查谁持有主机密钥

链保护 TLS,但另一条信任边界是 SSH 访问。主机密钥是你 SSH 进去的那台服务器的部署记录。ssh-host-key 把主机密钥当作你信任连接前就固定的记录:

$ ssh-keygen -lf ~/.ssh/known_hosts | grep www.xbcatm.com

确认指纹与你当初记录主机时的一致。重建后主机密钥变化,可能是合法的重装,也可能是该停下的理由——这正是主机密钥策略作为 web-security 边界存在的原因,而不只是便利。

即使无脚本静态站也可能设置 cookie;若它设置了,其作用域就是一种安全声明。cookie-scope 说 cookie 的主机、路径、传输与同站边界应比应用触达更窄。审计不要让 cookie 以超出必要的范围设置,对敏感内容优先 Secure; HttpOnly; SameSite。

把审计当清单

信号读作笔记
CSP script-src 'none'无脚本策略在线security-headers
HSTS includeSubDomains传输承诺是粘性的hsts-rollout
证书 subject+到期身份是具体日期certificate-expiry
链显示 4 张证书中间证书被发送certificate-chain
重新验证回 304缓存验证器工作conditional
CAA 被授权只有 CA 能签发dns-caa
主机密钥指纹一致SSH 信任是一条记录ssh-host-key

每个信号原来是一条笔记;合起来它们是一份清单。在不到一分钟里用两三条只读命令就能全部加完,服务器分毫未动。

常规建议

这个审计应属于定时检查,而不只在发布时。若你已有的一套检查,把这两条 openssl 行与头部抓取加进去。把输出按日期开头的文件名保留,让后来的比较能显示漂移。这样即使什么都没变,静态站点也能保持证据优先。

来源