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 边界存在的原因,而不只是便利。
7. 让 Cookie 作用域比应用更窄
即使无脚本静态站也可能设置 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 行与头部抓取加进去。把输出按日期开头的文件名保留,让后来的比较能显示漂移。这样即使什么都没变,静态站点也能保持证据优先。
来源
- RFC 9110 — HTTP Semantics,验证器与条件请求,已核验 2026-08-06。
- RFC 6797 — HSTS,传输策略,已核验 2026-08-06。
- RFC 5280 — X.509 PKI,证书链,已核验 2026-08-06。
- OpenSSL
s_client,链与身份检查,已核验 2026-08-06。 - dig(1),DNS 记录查找,已核验 2026-08-06。