在 ASP.NET Core 开发环境中为自定义域名签发受信任的自签名证书—HSTS 启用后的完整实践

一、问题的起点:HSTS 改变了开发调试的规则

平时在本地用自签名证书时,浏览器会弹出一个红色警告页,我们习惯性地点一下"继续前往(不安全)"就进去了。这种"凑合能用"的状态,在启用 HSTS(HTTP Strict Transport Security,HTTP 严格传输安全)之后会彻底失效

原因在于 HSTS 的语义本身:它告诉浏览器"这个域名只能通过安全连接访问,不接受任何不安全的回退"。因此一旦某域名进入 HSTS 生效状态,浏览器会移除证书错误页上的"继续访问"按钮,连接直接以 NET::ERR_CERT_AUTHORITY_INVALID 之类的硬错误终止,没有任何手动绕过的余地。

这意味着,在 HSTS 场景下,开发环境的目标不再是"生成一张能勉强用的自签名证书",而是要满足一个更高的标准:一张被操作系统和浏览器真正信任、并且覆盖你所有自定义域名与子域名的证书。本文就围绕这个目标,从原理到落地完整展开。

二、先厘清两个常被混为一谈的问题

很多人在配置时把两件事搅在一起,导致排错时方向全错。它们其实是相互独立的两个维度:

  • 问题 A——证书是否"覆盖"这个域名:证书里有没有声明它能为 auth.myapp.test 这个名字背书。这由证书的 SAN(Subject Alternative Name,主题备用名称)决定。
  • 问题 B——域名是否"解析"到本机:浏览器输入 auth.myapp.test 后,这个名字能不能被解析到 127.0.0.1。这由 hosts 文件或本地 DNS(Domain Name System,域名系统)决定。

两者必须同时成立,缺一不可:

                       域名能否解析到本机?
                       否                  是
                 ┌──────────────────┬──────────────────┐
              否 │ 既连不上          │ 连得上, 但证书    │
   证书是否       │ 也不受信任        │ 报错(名称不匹配)  │
   覆盖该域名? ───┼──────────────────┼──────────────────┤
              是 │ 证书没问题, 但    │ ✓ 正常工作        │
                 │ 根本访问不到      │                   │
                 └──────────────────┴──────────────────┘

记住这张图:本文第三到六节解决"问题 A",第七节解决"问题 B"。

三、HSTS 之下,证书必须满足的三个硬性条件

现代浏览器( Chrome 、Edge、Firefox 等)对一张可信的 TLS(Transport Layer Security,传输层安全)证书有三个不可妥协的要求。HSTS 只是让"不满足时的后果"从"可绕过的警告"变成"不可绕过的失败"。

条件一:SAN 必须包含你访问用的每一个域名。 自 Chrome 58(2017 年)起,浏览器完全忽略 CN(Common Name,通用名称),只读取 SAN 来判断证书是否对应当前域名。换句话说,把域名写在 CN 里毫无意义,必须写进 SAN。

SAN 里使用通配符时,有一组容易踩坑的规则,它直接关系到你 HSTS 的 includeSubDomains 能否真正落地:

通配符写法 能覆盖 不能覆盖
*.myapp.test auth.myapp.testapi.myapp.test(仅一级子域) 顶级域 myapp.test 本身;多级子域 a.b.myapp.test

由此得出两条实践准则:顶级域要单独列入 SAN(通配符不覆盖它),并且通配符只管一级。如果你启用了 HSTS 的 includeSubDomains,浏览器会强制所有子域走 HTTPS,那么你实际会访问到的每一个子域,SAN 都必须能匹配,否则该子域将直接无法打开。

一个稳妥的 SAN 清单示例:

DNS: myapp.test            ← 顶级域, 必须单列
DNS: *.myapp.test          ← 覆盖一级子域
DNS: auth.myapp.test       ← 关键子域显式列出, 不依赖通配符
DNS: localhost
IP : 127.0.0.1

条件二:证书必须带有 serverAuth 的 EKU。 EKU(Extended Key Usage,扩展密钥用法)声明了证书的用途。用于 HTTPS 服务端的证书,必须包含 “Server Authentication” 这一项,否则即使被信任也会被浏览器拒绝。

条件三:证书(或其签发链)必须在系统/浏览器的信任库中。 这是 HSTS 场景下最关键、也最容易被忽视的一条。没有 HSTS 时,不受信任只是弹个警告;有了 HSTS,不受信任就是硬性失败,毫无退路。

下面这张图把三个条件串成了浏览器实际的校验流程,也直观展示了 HSTS 如何抽掉了最后的安全网:

不能

匹配

不匹配

浏览器收到服务器证书

证书链能否追溯到
受信任的根证书?

SAN 是否匹配
当前访问的域名?

证书不受信任

连接安全 (绿色锁)

该域名是否处于
HSTS 生效状态?

显示警告页
可点击'继续访问'

硬性失败
无任何绕过手段

四、动手之前:本地开发顶级域(TLD)的隐藏陷阱

在为开发环境挑选域名(如 myapp.???)时,顶级域 TLD(Top-Level Domain,顶级域名)的选择本身就藏着几个坑,选错会让你在 HSTS 上额外受罪:

顶级域 是否推荐 原因
.dev.app 绝不要用 它们是真实存在的公共 TLD,且整个 TLD 已被加入浏览器的 HSTS 预加载列表,浏览器对它们强制 HTTPS。用于本地开发既混淆视听,又徒增麻烦。
.local 不推荐 被 RFC 6762 保留给 mDNS(multicast DNS,多播 DNS)。在 macOS(Bonjour)和 Linux(Avahi)上,.local 的解析会走 mDNS,可能与 hosts 条目冲突或造成解析延迟。
.localhost 可用 被 RFC 6761 保留。浏览器把 *.localhost(如 app.localhost)自动解析到回环地址,无需改 hosts,且被视为安全上下文。
.test 推荐 被 RFC 6761 明确保留用于测试,永远不会成为真实 TLD,不与任何公网域名冲突,适合本地开发。

本文统一采用 .test(如 myapp.testauth.myapp.test)作为示例。这里还有一个值得知道的背景:localhost 之所以平时不用证书也能跑 Service Worker 等特性,是因为浏览器把它当作"潜在可信来源(secure context)"特殊对待;而你的自定义域名不享受这个待遇,这正是必须为它们准备真实可信证书的根本原因。

五、理解信任链:自签名为什么"需要被自己信任"

要理解后面几种生成方式的差异,先要看懂证书的信任链。浏览器信任一张证书,本质是能把它沿着"签发关系"一路追溯到一个已在系统信任库里的根证书

由谁签发

必须存在于

叶证书
(服务器用的那张)

中间/根 CA 证书

系统信任库
(受信任的根证书颁发机构)

这就引出两种思路的根本区别:

  • 纯自签名证书(方式 B、C):这张证书"自己签发自己",它既是叶证书也是根。要让它被信任,只能把它本身塞进系统的"受信任的根证书颁发机构"库。简单,但每换一张证书就要重新信任一次。
  • 本地 CA 签发(方式 A,mkcert):先在本机创建一个本地 CA(Certificate Authority,证书颁发机构),把这个 CA 装进信任库一次;此后由它签发的所有叶证书都自动被信任,结构更干净,扩展更方便。

理解了这层关系,下面三种方式的取舍就清晰了。

六、三种生成方式

补充一句:ASP.NET Core 自带的 dotnet dev-certs https 工具只能为 localhost 生成证书,无法添加自定义 SAN,因此不适用于本文场景,需要我们自己生成。

方式 A(强烈推荐):mkcert —— 自动建立受信任的本地 CA

mkcert 会在本机创建并信任一个本地 CA(同时装入系统信任库和 Firefox 自带的库),之后它签发的所有证书天然受信任。这恰好把 HSTS 最难的"必须被信任"一步自动化了,摩擦最小。

# 安装(Windows 任选其一)
choco install mkcert
# 或 scoop install mkcert

# 创建并信任本地 CA(整台机器只需做一次)
mkcert -install

# 生成覆盖多域名 / 子域名的证书
mkcert myapp.test "*.myapp.test" auth.myapp.test localhost 127.0.0.1 ::1
# 产物类似: myapp.test+5.pem(证书) 与 myapp.test+5-key.pem(私钥)

后续若要新增子域,重新跑一条 mkcert 命令即可,无需任何手动信任操作。

方式 B:PowerShell New-SelfSignedCertificate(Windows 原生,无需安装)

适合不想装额外工具的 Windows 环境。以管理员身份运行 PowerShell:

# 1) 生成证书: -DnsName 自动填充 SAN, -Type 设置 serverAuth EKU
$cert = New-SelfSignedCertificate `
  -Subject "CN=myapp.test" `
  -DnsName "myapp.test","*.myapp.test","auth.myapp.test","localhost" `
  -KeyAlgorithm RSA -KeyLength 2048 `
  -NotAfter (Get-Date).AddYears(2) `
  -CertStoreLocation "Cert:\LocalMachine\My" `
  -FriendlyName "MyApp Dev Cert" `
  -Type SSLServerAuthentication

# 2) 将其加入"受信任的根证书颁发机构"——HSTS 能正常工作的关键一步
$root = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root","LocalMachine")
$root.Open("ReadWrite"); $root.Add($cert); $root.Close()

# 3) 导出 PFX 供 Kestrel 使用
$pwd = ConvertTo-SecureString -String "DevPwd_ChangeMe" -Force -AsPlainText
Export-PfxCertificate -Cert $cert -FilePath ".\myapp-dev.pfx" -Password $pwd

要点:-Type SSLServerAuthentication 自动赋予 serverAuth EKU;-DnsName 自动写入 SAN;自签名证书既是叶也是根,放进 Root 库即完成信任。开发自测足够,但不如 mkcert 的"独立 CA + 叶证书"结构清爽。

方式 C:OpenSSL(跨平台、完全可控)

适合 Linux / macOS 或 CI(Continuous Integration, 持续集成 )流水线。用 -addext 一条命令同时带上 SAN 与 EKU:

# 1) 生成自签名证书 + 私钥
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout myapp-dev.key -out myapp-dev.crt \
  -days 730 -subj "/CN=myapp.test" \
  -addext "subjectAltName=DNS:myapp.test,DNS:*.myapp.test,DNS:auth.myapp.test,DNS:localhost,IP:127.0.0.1" \
  -addext "extendedKeyUsage=serverAuth"

# 2) 合并为 PFX(Personal Information Exchange,个人信息交换)供 Kestrel 使用
openssl pkcs12 -export -out myapp-dev.pfx \
  -inkey myapp-dev.key -in myapp-dev.crt \
  -passout pass:DevPwd_ChangeMe

OpenSSL 不会自动信任,需手动导入 myapp-dev.crt:Windows 上双击 → 安装到"本地计算机" → “受信任的根证书颁发机构”;macOS 在钥匙串中将其设为"始终信任"。

七、让域名解析到本机:hosts 与本地 DNS

证书备齐后,要解决第二节的"问题 B"——让 myapp.test 这类名字解析到本机。编辑 hosts 文件:

  • Windows:C:\Windows\System32\drivers\etc\hosts(需管理员)
  • Linux / macOS:/etc/hosts
127.0.0.1   myapp.test
127.0.0.1   auth.myapp.test
127.0.0.1   api.myapp.test

一个关键限制:hosts 文件不支持通配符,即便证书里有 *.myapp.test,每个子域仍需逐行写入。如果子域很多、希望 *.myapp.test 自动解析,需要本地 DNS 工具(Windows 用 Acrylic DNS Proxy,Linux/macOS 用 dnsmasq)来做通配解析,这属于进阶配置。大多数开发场景把常用几个子域写进 hosts 即可。

顺带一提:若你采用前述的 .localhost 方案,app.localhost 之类的子域会被浏览器自动解析到回环地址,连 hosts 都不用改——这是 .localhost 相较 .test 的一个便利之处,代价是它在某些工具链里不如真实风格的域名通用。

八、把证书接入 Kestrel

最直接的方式是在 appsettings.Development.json 里为端点指定证书:

{
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://0.0.0.0:5001",
        "Certificate": {
          "Path": "myapp-dev.pfx",
          "Password": "DevPwd_ChangeMe"
        }
      }
    }
  }
}

更推荐避免硬编码密码。如果采用方式 B 把证书放进了 LocalMachine\My,可直接按存储区引用,完全不出现密码:

"Certificate": {
  "Subject": "myapp.test",
  "Store": "My",
  "Location": "LocalMachine",
  "AllowInvalid": false
}

或用代码方式,密码交给 user-secrets 管理:

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(5001, listen =>
    {
        listen.UseHttps("myapp-dev.pfx",
            builder.Configuration["DevCertPassword"]); // 来自 user-secrets
    });
});

mkcert 产出的 PEM(Privacy-Enhanced Mail)文件也能直接用:

listen.UseHttps(
    X509Certificate2.CreateFromPemFile("myapp.test+5.pem", "myapp.test+5-key.pem"));

九、HSTS 专属排错

整条链路里,HSTS 会带来几个它特有的坑,单独列出:

1. 之前失败留下的 HSTS 状态会持续干扰。 如果你早先用未受信任的证书访问过这些域名,浏览器可能已经为它们记下了 HSTS/证书 策略 。换上新证书后若仍报错,需手动清除:Chrome/Edge 打开 chrome://net-internals/#hsts,在 “Delete domain security policies” 输入域名(如 myapp.test)逐个删除,然后重启浏览器。这一步在调试初期几乎是必做的。

2. 想在开发环境彻底不发 HSTS 头,可保留模板自带的环境判断(if (!app.Environment.IsDevelopment()) 才调用 app.UseHsts()),或把域名加入排除列表:

builder.Services.AddHsts(options =>
{
    options.ExcludedHosts.Add("myapp.test");
    options.ExcludedHosts.Add("auth.myapp.test");
});

注意:ExcludedHosts 默认只排除 localhost127.0.0.1[::1],不含你的自定义域名;而且"不再发头"并不会清除浏览器已经记住的旧策略,旧策略仍需按第 1 点手动清理。

3. 反向代理场景下 HSTS 头可能根本发不出。 app.UseHsts() 只在请求本身是 HTTPS 时才添加 Strict-Transport-Security 头。如果本地前置了 HTTP 反向代理(或 YARP),必须正确配置 ForwardedHeadersMiddleware 识别 X-Forwarded-Proto,否则应用以为请求是 HTTP,会跳过发送 HSTS 头。

十、验证清单

按顺序逐项确认,绝大多数问题都能定位:

[ ] 证书 SAN 是否包含你访问的每个域名/子域(顶级域单列、通配符只管一级)
[ ] 证书是否带 serverAuth 的 EKU
[ ] 证书(或其 CA)是否在"受信任的根证书颁发机构"中  ← HSTS 下最常见的失败点
[ ] 选用的顶级域是否安全(避开 .dev/.app/.local, 优先 .test/.localhost)
[ ] hosts 是否把每个子域都指向 127.0.0.1(hosts 不支持通配符)
[ ] Kestrel 是否正确加载了这张证书(查看启动日志里的监听地址)
[ ] 浏览器是否残留旧的 HSTS/证书策略(chrome://net-internals/#hsts 清理后重启)
[ ] 反向代理场景: 转发头中间件是否配好

十一、小结

启用 HSTS 后,开发环境的证书工作可以浓缩为一个心智模型:HSTS 抽掉了"点击继续"这张安全网,于是自签名证书必须从"能凑合"升级为"被真正信任、且覆盖所有目标域名"。

落地时把握三件事就不会乱:其一,分清"证书覆盖域名(SAN)"与"域名解析到本机(hosts)"是两个独立问题,二者缺一不可;其二,证书必须同时满足 SAN 匹配、serverAuth EKU、进入信任库三个硬条件,而信任库是 HSTS 下最容易翻车的一环;其三,域名本身的选择有讲究,避开 .dev/.app/.local,优先 .test.localhost

工具层面的建议很明确:开发环境首选 mkcert,它把"建立受信任 CA → 签发含 SAN 的证书 → 装入信任库"一次性解决,与 HSTS"必须被信任"的要求天然契合,新增子域只需重跑一条命令;若不便安装工具,Windows 上 New-SelfSignedCertificate 配合导入 Root 库同样完全够用。

posted @ 2026-06-10 07:50  无风听海  阅读(41)  评论(0)    收藏  举报