Grading Security Headers Isn't the Full Picture
Grading Security Headers Isn’t the Full Picture
仅靠安全响应头评分并不全面
A few days ago someone left a comment on my last post about grading security headers automatically: “Security-header grading is most useful when it explains tradeoffs, not only pass/fail. CSP especially needs context because a stricter policy can be correct technically and still break the product if rollout is blind.” 前几天,有人在我上一篇关于自动评估安全响应头(Security Headers)的文章下留言道:“安全响应头评分最有价值的地方在于解释权衡(tradeoffs),而不仅仅是简单的通过/失败。特别是内容安全策略(CSP),它需要上下文,因为如果盲目部署,即使技术上正确的严格策略也可能导致产品崩溃。”
Fair hit. My original grader read Content-Security-Policy and Content-Security-Policy-Report-Only as the same thing — a strict policy still in report-only mode (i.e., not actually blocking anything yet) scored identically to one fully enforced. That’s not a rounding error, it’s a real blind spot: a team mid-rollout with a report-only CSP gets the same green checkmark as a team that shipped a broken policy and never noticed.
说得对。我最初的评分器将 Content-Security-Policy 和 Content-Security-Policy-Report-Only 视为同等对待——一个处于仅报告模式(即尚未实际拦截任何内容)的严格策略,与一个完全强制执行的策略得分相同。这不仅仅是四舍五入的误差,而是一个真正的盲点:一个处于部署中期、仅开启报告模式的团队,与一个发布了错误策略却未察觉的团队,竟然获得了同样的绿色勾选标记。
Fixing that made me look harder at what grading security from headers alone actually misses — and the biggest gap isn’t in the headers at all. It’s in the TLS layer underneath them. Headers tell you intent. TLS tells you reality. 修复这个问题让我更深入地思考:仅通过响应头进行安全评估到底遗漏了什么?最大的差距根本不在响应头里,而是在其底层的 TLS 层。响应头告诉你的是“意图”,而 TLS 告诉你的是“现实”。
Strict-Transport-Security says “browsers should only ever load me over HTTPS.” It says nothing about whether the certificate serving that HTTPS connection is valid right now, how many days until it expires, which TLS version actually got negotiated (a server can advertise HSTS and still fall back to TLS 1.0 for older clients), or who issued it. A site can have a perfect header score and be running on a cert that expires in 6 days. Headers audit configuration; a live handshake audits ground truth.
Strict-Transport-Security(HSTS)声明“浏览器应始终通过 HTTPS 加载我”。但它并没有说明提供该 HTTPS 连接的证书当前是否有效、距离过期还有多少天、实际协商的是哪个 TLS 版本(服务器可以声明 HSTS,但仍可能为旧客户端回退到 TLS 1.0),或者证书是谁签发的。一个网站可能拥有完美的响应头评分,但其证书却在 6 天后过期。响应头审计的是配置,而实时握手审计的才是事实真相。
Doing the handshake for real: 进行真实握手:
import ssl, socket
from datetime import datetime
def get_cert_info(hostname: str, port: int = 443, timeout: float = 3.0):
ctx = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
version = ssock.version()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
return {
"issuer": dict(x[0] for x in cert["issuer"]),
"valid_until": not_after.isoformat(),
"tls_version": version,
"is_expired": datetime.utcnow() > not_after,
}
Three things matter beyond the happy path: 除了理想情况,还有三点至关重要:
-
It’s blocking I/O. In an async service, wrap it:
await loop.run_in_executor(None, get_cert_info, hostname). -
它是阻塞式 I/O。在异步服务中,请将其包装:
await loop.run_in_executor(None, get_cert_info, hostname)。 -
It has to fail soft. Self-signed certs, non-TLS ports, and slow/unreachable hosts are all normal inputs here, not exceptions to crash on — catch broadly and return None, don’t let a cert-inspection add-on take down a response that would otherwise be fine.
-
必须实现“软失败”(fail soft)。自签名证书、非 TLS 端口以及缓慢/无法访问的主机在这里都是正常的输入,而不是导致崩溃的异常——请进行广泛的捕获并返回
None,不要让一个证书检查插件导致原本正常的响应崩溃。 -
It’s a second round-trip you didn’t need before. If your header audit only reads response headers you already have in memory, bolting on a live handshake by default silently doubles your latency for every caller, most of whom never asked for it. Make it opt-in, not default.
-
这是你之前不需要的第二次往返(round-trip)。如果你的响应头审计只读取内存中已有的响应头,那么默认添加实时握手会悄悄地使每个调用者的延迟翻倍,而他们中的大多数人从未要求这样做。请将其设为可选(opt-in),而不是默认开启。
That third point is why I didn’t fold this into the existing security-audit endpoint’s default behavior — I gated it behind a query flag (include_tls_details=true) that’s off unless explicitly requested. The base audit stays header-only and fast; anyone who wants the deeper cert check asks for it and pays the extra round-trip knowingly.
第三点就是我没有将其合并到现有安全审计端点默认行为中的原因——我将其限制在一个查询参数(include_tls_details=true)之后,除非明确请求,否则默认关闭。基础审计保持仅针对响应头且速度快;任何想要更深入证书检查的人都可以主动请求,并知情地承担额外的往返延迟。
I ended up shipping both fixes — Report-Only-aware header context and opt-in TLS inspection — in the API from my last post: 我最终在上一篇文章提到的 API 中发布了这两个修复程序——支持“仅报告模式”的响应头上下文和可选的 TLS 检查:
GitHub: https://github.com/JosejuX/rapidapi-metadata-extractor Try it: https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor
Thanks again to the commenter who pushed on this — pass/fail without the why is exactly the kind of audit tool that trains people to chase a green checkmark instead of understanding the header. 再次感谢那位提出建议的评论者——没有解释原因的“通过/失败”评分,正是那种只会训练人们去追求绿色勾选标记,而不是去理解响应头本身含义的审计工具。