一、什么是 HSTS
HSTS(HTTP Strict Transport Security,HTTP 严格传输安全)是一种由 RFC 6797 定义的 Web 安全策略机制。它的核心思想很简单:网站通过一个响应头告诉浏览器"今后只能用 HTTPS 访问我" ,浏览器记住这个指令后,在指定有效期内会强制将所有对该域名的请求转换为 HTTPS,即使用户手动输入 http:// 或点击了 HTTP 链接。
在 ASP.NET Core 中,app.UseHsts() 就是用来注册这个中间件的,它会在 HTTPS 响应中自动添加 Strict-Transport-Security 响应头。
二、为什么需要 HSTS:它解决的核心问题
要理解 HSTS 的价值,必须先理解它要防御的攻击场景。
2.1 中间人攻击与 SSL 剥离(SSL Stripping)
考虑一个典型流程:用户在浏览器地址栏输入 example.com(没有写协议)。浏览器默认会先发起一个 HTTP 请求:
GET http://example.com/ → 301 Redirect to https://example.com/
问题在于第一次的 HTTP 请求是明文的。如果用户处在不可信网络(公共 WiFi、被劫持的路由器)中,攻击者可以:
- 拦截这个明文 HTTP 请求;
- 自己以 HTTPS 与真实服务器通信,但与用户始终保持 HTTP 连接;
- 用户全程"以为"自己在正常浏览,实际所有数据对攻击者完全透明。
这就是 SSL Stripping(SSL 剥离)攻击 ,由安全研究员 Moxie Marlinspike 在 2009 年提出。仅靠服务端的 301 重定向无法防御它,因为攻击发生在重定向到达浏览器之前。
2.2 HSTS 如何打破这个攻击链
一旦浏览器收到过该域名的 HSTS 头并记住它,后续行为就变成:
用户输入 example.com
→ 浏览器内部直接改写为 https://example.com(307 Internal Redirect,根本不发出 HTTP 请求)
→ 攻击者没有任何明文请求可以拦截
关键区别在于:重定向从"服务器告诉浏览器"变成了"浏览器自己强制执行",明文请求这个攻击窗口被彻底消除。
三、Strict-Transport-Security 响应头剖析
UseHsts() 最终产生的就是这样一个头:
Strict-Transport-Security: max-age=2592000; includeSubDomains; preload
它由三个指令组成:
| 指令 | 含义 |
|---|---|
max-age |
浏览器记住"仅 HTTPS"策略的秒数。每次收到该头都会刷新计时。max-age=0 表示立即清除策略。 |
includeSubDomains |
策略同时应用于所有子域名。例如对 example.com 设置后,a.example.com、b.example.com 也被强制 HTTPS。 |
preload |
申请将域名加入浏览器内置的 HSTS 预加载列表(详见第六节)。这不是 RFC 标准指令,而是浏览器厂商约定。 |
四、ASP.NET Core 中的配置详解
4.1 默认行为
模板生成的代码通常是这样:
csharp
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts(); // 仅在非开发环境启用
}
app.UseHttpsRedirection();
注意一个重要细节:默认 max-age 是 30 天(2,592,000 秒),而不是 RFC 推荐用于生产的较长值。这是框架刻意保守的选择------避免你在测试阶段误配后被长期"锁死"。
4.2 自定义配置
通过 AddHsts 在服务容器中配置选项:
csharp
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365); // 生产推荐至少 1 年
options.ExcludedHosts.Add("localhost"); // 默认已包含一系列本地主机名
options.ExcludedHosts.Add("internal.mycorp.lan");
});
HstsOptions 的核心成员:
| 选项 | 类型 | 说明 |
|---|---|---|
MaxAge |
TimeSpan |
映射到 max-age,默认 30 天 |
IncludeSubDomains |
bool |
是否添加 includeSubDomains,默认 false |
Preload |
bool |
是否添加 preload,默认 false |
ExcludedHosts |
IList<string> |
不发送 HSTS 头的主机名列表 |
4.3 ExcludedHosts 的默认值
这是容易踩坑的地方。ExcludedHosts 默认已经包含以下主机,因此即便你启用了 HSTS,访问这些地址也不会收到该头:
localhost127.0.0.1(IPv4 回环)[::1](IPv6 回环)
设计原因是:HSTS 策略一旦被浏览器记住,会强制整个 localhost 走 HTTPS,这会干扰本机上其他用 HTTP 调试的项目。框架默认排除它们以避免污染开发环境。如果你想为某个域名启用 HSTS,又恰好该域名在排除列表中,需要手动清空或移除。
五、中间件的工作机制与放置顺序
5.1 只在 HTTPS 连接上发送
UseHsts() 中间件有一个常被忽略的行为:它只在请求本身是 HTTPS 时才添加响应头 。这是 RFC 6797 的强制要求------HSTS 头在明文 HTTP 响应中必须被浏览器忽略,因为一个能被篡改的明文头毫无安全意义。所以如果你的应用部署在反向代理后面,代理用 HTTP 与 Kestrel 通信,就必须正确配置 ForwardedHeadersMiddleware,让应用通过 X-Forwarded-Proto 识别出原始请求是 HTTPS,否则 HSTS 头永远不会发出。
5.2 UseHsts 与 UseHttpsRedirection 的分工
两者经常一起出现,但职责不同,互为补充:
#mermaid-svg-ZCO4EOI4ltgn78WQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZCO4EOI4ltgn78WQ .error-icon{fill:#552222;}#mermaid-svg-ZCO4EOI4ltgn78WQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZCO4EOI4ltgn78WQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .marker.cross{stroke:#333333;}#mermaid-svg-ZCO4EOI4ltgn78WQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZCO4EOI4ltgn78WQ p{margin:0;}#mermaid-svg-ZCO4EOI4ltgn78WQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster-label text{fill:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster-label span{color:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster-label span p{background-color:transparent;}#mermaid-svg-ZCO4EOI4ltgn78WQ .label text,#mermaid-svg-ZCO4EOI4ltgn78WQ span{fill:#333;color:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .node rect,#mermaid-svg-ZCO4EOI4ltgn78WQ .node circle,#mermaid-svg-ZCO4EOI4ltgn78WQ .node ellipse,#mermaid-svg-ZCO4EOI4ltgn78WQ .node polygon,#mermaid-svg-ZCO4EOI4ltgn78WQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .rough-node .label text,#mermaid-svg-ZCO4EOI4ltgn78WQ .node .label text,#mermaid-svg-ZCO4EOI4ltgn78WQ .image-shape .label,#mermaid-svg-ZCO4EOI4ltgn78WQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZCO4EOI4ltgn78WQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .rough-node .label,#mermaid-svg-ZCO4EOI4ltgn78WQ .node .label,#mermaid-svg-ZCO4EOI4ltgn78WQ .image-shape .label,#mermaid-svg-ZCO4EOI4ltgn78WQ .icon-shape .label{text-align:center;}#mermaid-svg-ZCO4EOI4ltgn78WQ .node.clickable{cursor:pointer;}#mermaid-svg-ZCO4EOI4ltgn78WQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .arrowheadPath{fill:#333333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZCO4EOI4ltgn78WQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZCO4EOI4ltgn78WQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZCO4EOI4ltgn78WQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster text{fill:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ .cluster span{color:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZCO4EOI4ltgn78WQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZCO4EOI4ltgn78WQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZCO4EOI4ltgn78WQ .icon-shape,#mermaid-svg-ZCO4EOI4ltgn78WQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZCO4EOI4ltgn78WQ .icon-shape p,#mermaid-svg-ZCO4EOI4ltgn78WQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZCO4EOI4ltgn78WQ .icon-shape .label rect,#mermaid-svg-ZCO4EOI4ltgn78WQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZCO4EOI4ltgn78WQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZCO4EOI4ltgn78WQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZCO4EOI4ltgn78WQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否(HTTP)
是(HTTPS)
请求到达
是 HTTPS 吗?
UseHttpsRedirection
返回 307/308 重定向到 HTTPS
UseHsts
添加 Strict-Transport-Security 头
浏览器重新发起 HTTPS 请求
浏览器记住策略
后续直接走 HTTPS
UseHttpsRedirection:负责把当前这次 HTTP 请求重定向到 HTTPS(服务端动作)。UseHsts:负责让未来所有请求由浏览器自己强制 HTTPS(客户端动作)。
两者结合:第一次访问靠重定向兜底,之后靠 HSTS 从根本上杜绝明文请求。
5.3 推荐的中间件顺序
UseHsts() 应放在管道较前的位置,通常紧随异常处理之后、UseHttpsRedirection 之前:
csharp
app.UseExceptionHandler("/Error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ...
六、HSTS 预加载列表(Preload List)
6.1 什么是预加载
HSTS 有一个固有的"首次访问"缺口:浏览器从未访问过某域名时,它还没收到过 HSTS 头,因此第一次访问仍可能走 HTTP,存在被攻击的窗口。
预加载列表解决了这个问题。它是一份由 Google 维护、被 Chrome、Firefox、Safari、Edge 等主流浏览器内置编译进二进制的域名清单。列表中的域名从浏览器安装的第一刻起就被强制 HTTPS,连首次访问的缺口都不存在。
6.2 加入预加载的硬性条件
要在 hstspreload.org 提交域名,必须同时满足:
- 提供有效的 HTTPS 证书;
- 将所有 HTTP 流量重定向到同主机的 HTTPS;
- 所有子域名都支持 HTTPS;
- 在 HTTPS 的根域名响应中发送 HSTS 头,且:
max-age至少为 31,536,000 秒(1 年);- 包含
includeSubDomains; - 包含
preload。
对应的 ASP.NET Core 配置:
csharp
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
6.3 预加载是把双刃剑
加入容易、移除极难是必须认清的现实。一旦进入预加载列表,想退出需要提交移除申请,并等待数周乃至数月,等待新版本浏览器发布并被用户更新。在此期间,列表中的域名及其所有子域名都无法通过 HTTP 提供服务。
因此实践建议是:确认所有子域名(包括未来可能新建的)都能长期支持 HTTPS 后再开启 preload,否则一个还在用 HTTP 的内部子域名会突然全面不可访问。
七、实践中的常见陷阱
陷阱一:开发环境误启用导致本机被锁。 若在开发中对 localhost 发出了 HSTS 头(例如手动从 ExcludedHosts 移除了它),浏览器会记住策略,导致本机其他 HTTP 调试项目无法访问。清除方法是在 Chrome 访问 chrome://net-internals/#hsts,在 "Delete domain security policies" 中输入域名删除。
陷阱二:反向代理下头未发出。 如前所述,代理与应用间是 HTTP 时,必须配好转发头中间件,否则应用认为请求是 HTTP,跳过发送 HSTS 头。
陷阱三:max-age 设置过大却未充分测试。 一旦发出长 max-age,在证书出问题或想临时回退 HTTP 时,用户浏览器仍会强制 HTTPS 且无法访问。回退唯一手段是发送 max-age=0 的头,但只有再次成功访问 HTTPS 的用户才能收到这个"解锁"指令。建议先用较短 max-age(如几分钟到几天)灰度验证,确认稳定后再逐步调长。
陷阱四:误以为 HSTS 能替代 HTTPS 重定向。 它不能。对从未访问过、又不在预加载列表中的用户,仍需 UseHttpsRedirection 处理首次的 HTTP 请求。两者缺一不可。
八、总结
app.UseHsts() 是一行看似简单的代码,背后是一套针对 SSL 剥离攻击的纵深防御机制。它的本质是把 HTTPS 的强制权从服务端转移到客户端,从而消除明文请求这个最脆弱的攻击面。
正确使用它需要把握几个要点:理解 max-age、includeSubDomains、preload 三个指令的含义与风险;清楚它只在 HTTPS 连接上生效、需与 UseHttpsRedirection 配合;警惕 ExcludedHosts 的默认行为和预加载列表"难以撤回"的特性。生产环境的稳妥做法是:先以保守的 max-age 验证整条链路(含反向代理转发头),稳定后再逐步调长、考虑子域名覆盖,最后在确有把握时才申请预加载。