HTTPS是确保传输安全最主要的手段,并且已经成为了互联网默认的传输协议。不知道读者朋友们是否注意到当我们利用浏览器(比如Chrome)浏览某个公共站点的时候,如果我们输入的是一个HTTP地址,在大部分情况下浏览器会自动重定向到对应HTTPS地址。这一特性源于浏览器和服务端针对HSTS(HTTP Strict Transport Security)这一HTTP规范的支持。ASP.NET利用HstsMiddleware和HttpsRedirectionMiddleware这两个中间件提供了对HSTS的实现。(本文提供的示例演示已经同步到《ASP.NET Core 6框架揭秘-实例演示版》)

[S2401]构建HTTPS站点

虽然目前绝大部分的公共站点都提供了HTTPS终结点,但是由于用户多年养成的习惯,以及客户端(以浏览器为主的User Agent)提供的一些自动化行为,导致针对站点的初始请求依然采用HTTP协议,所以站点还是会提供一个HTTP终结点。为了尽可能地采用HTTPS协议进行通信,“国际互联网工程组织(IETF)”制定了一份名为“HSTS(HTTP Strict Transport Security)”的安全规范或者协议,ASP.NET针对HSTS的实现是由THstsMiddleware和HttpsRedirectionMiddleware这两个中间件来完成的。接下来我们利用一个简单的实例演示来介绍HSTS旨在解决的问题,以及针对这两个中间件的使用。

HTTPS站点会绑定一张证书,并利用证书提供的密钥对(公钥/私钥对)在前期通过协商生成一个用来对传输内容进行加解密的密钥。HTTPS站点绑定的证书相当于该站点的“身份证”,它解决了服务端认证(确定当前访问的不是一个钓鱼网站)的问题。我们之所以能够利用证书来确定站点的正式身份,源于证书具有的两个特性:第一,证书不能篡改,附加了数字签名的证书可以很容易地确定当前的内容是否与最初生成时一致;第二,证书由权威机构签发,公共站点绑定的证书都是从少数几个具有资质的提供商购买的。

我们演示的程序涉及的通信仅限于本机范围,并不需要需要真正地从官方渠道去购买一张证书,所以我们选择创建一个“自签名”证书。自签名证书的创建可以采用多种方式,我们采用如下的方式在PowerShell中执行New-SelfSignedCertificate命令创建了针对“artech.com”,“blog.artech.com”和“foobar.com”域名的三张证书。

New-SelfSignedCertificate -DnsName artech.com -CertStoreLocation "Cert:\CurrentUser\My"
New-SelfSignedCertificate -DnsName blog.artech.com
    -CertStoreLocation "Cert:\CurrentUser\My"
New-SelfSignedCertificate -DnsName foobar.com -CertStoreLocation "Cert:\CurrentUser\My"

在执行New-SelfSignedCertificate命令的时候,我们利用-CertStoreLocation参数为生成的证书指定了存储位置。证书在Windows系统下是针对“账号类型”进行存储的,具体的账号分为如下三种类型,证书总是存储在某种账户类型下某个位置。对于生成在自签名证书,我们将存储位置设置为“Cert:\CurrentUser\My”,意味它们最终会存储在当前用户账户下的“个人(Personal)”存储中。

  • 当前用户账户(Current user account)
  • 机器账户(Machine account)
  • 服务账户(Service account)

我们可以利用Certificate MMC(Microsoft Management Console)查看生成的这三张证书。具体的做法是执行mmc命名开启一个MMC对话框,并选择菜单“File>Add/Remove Snap-In...”开启Snap-In窗口,在列表中选择“Certificate”选项。在弹出的证书存储类型对话框架中,我们选择“Current user account”选项。在最终开启的证书管理控制台上,我们可以在Personal存储节点中看到如图25-1所示的三张证书。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图1 手工创建的证书

由于我们创建的是三张“自签名”的证书,也就是自己给自己签发的证书,在默认情况下自然不具有广泛的信任度。为了解决这个问题,我们可以将它们导入到“Trusted Root Certification Authorities”存储节点中,这里存储的是代表信任签发机构的证书。我们以文件的形式将证书从“Personal”导出,然后再将证书文件导入到这里。注意在导出证书时应该选择“导出私钥”选项。为了能够通过证书绑定的域名访问站点,我们在hosts文件中将它们映射到本地IP地址(127.0.0.1)。

127.0.0.1 artech.com
127.0.0.1 blog.artech.com
127.0.0.1 foobar.com

在完成了域名映射、证书创建并解决了证书的“信任危机”之后,我们创建一个ASP.NET程序,并为注册的Kestrel服务器添加针对HTTP和HTTPS协议的终结点。如下面的代码片段所示,我们调用IWeHostBuilder接口的UseKestrel扩展方法添加的终结点采用默认端口(80和443),其中HTTPS终结点会利用SelelctCertificate方法根据提供的域名选择对应的证书,为“/{foobar?}”路径注册的终结点会将代表协议类型的Scheme作为响应内容。

using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using System.Net;
using System.Security.Cryptography.X509Certificates;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel (kestrel =>
{
    kestrel.Listen(IPAddress.Any, 80);
    kestrel.Listen(IPAddress.Any, 443, listener => listener.UseHttps(https => https.ServerCertificateSelector = SelelctCertificate));
});

var app = builder.Build();
app.MapGet("/{foobar?}", (HttpRequest request) => request.Scheme);
app.Run();

static X509Certificate2? SelelctCertificate(ConnectionContext? context,string? domain)
    => domain?.ToLowerInvariant() switch
    {
        "artech.com" => CertificateLoader.LoadFromStoreCert("artech.com", "My", StoreLocation.CurrentUser, true),
        "blog.artech.com" => CertificateLoader.LoadFromStoreCert("blog.artech.com", "My", StoreLocation.CurrentUser, true),
        "foobar.com" => CertificateLoader.LoadFromStoreCert("foobar.com", "My", StoreLocation.CurrentUser, true),
        _ => throw new InvalidOperationException($"Invalid domain '{domain}'.")
    };

程序启动之后,我们可以三个映射的域名已HTTP或者HTTPS的方式来访问它。图2示的就是使用域名“artech.com”分别发送HTTP和HTTPS请求后得到的结果。对于针对HTTP终结点的访问,浏览器还给予了一个“不安全(Not secure)”的警告。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图2 访问HTTP和HTTPS终结点

[S2402]HTTPS终结点重定向

从安全的角度来讲,我们肯定是希望用户的每个请求指向的都是HTTPS终结点,但是我们不可能要求用户在地址栏输入的URL都以“https”作为前缀,这个问题可以通过服务端以重定向的方式来解决。如图25-3所示,如果服务端接收到一个HTTP请求,它立即回复一个状态码为307的临时重定向响应,并将重定向地址指向对应的HTTPS终结点,那么浏览器会自动对新的HTTPS终结点重新发起请求。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图3 访问HTTP和HTTPS终结点

上述针对HTTPS终结点的自动重定向可以利用HttpsRedirectionMiddleware中间件来完成,我们可以按照如下的方式调用UseHttpsRedirection扩展方法来注册这个中间件,该中间件依赖的服务由AddHttpsRedirection扩展方法进行注册,我们在调用这个方法的同时对HTTPS终结点采用的端口号(443)进行了设置。

...
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel (kestrel =>
{
    kestrel.Listen(IPAddress.Any, 80);
    kestrel.Listen(IPAddress.Any, 443, listener => listener.UseHttps(https => https.ServerCertificateSelector = SelelctCertificate));
});
builder.Services.AddHttpsRedirection(options => options.HttpsPort = 443);

var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("/{foobar?}", (HttpRequest request) => request.Scheme);
app.Run();
...

改动后的程序启动后,如果我们请求“http://artech.com/foobar”这个URL,会自动被重定向到到新的地址“https://artech.com/foobar”。如下所示的是这个过程涉及到的两轮HTTP事务的请求和响应报文(S2402)。

GET http://artech.com/foobar HTTP/1.1
Host: artech.com

HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Sun, 19 Sep 2021 11:57:56 GMT
Server: Kestrel
Location: https:
GET https://artech.com/foobar HTTP/1.1
Host: artech.com

HTTP/1.1 200 OK
Date: Sun, 19 Sep 2021 11:57:56 GMT
Server: Kestrel
Content-Length: 5

https

[S2403]注册HstsMiddleware中间件

按照目前互联网的安全标准来看,以明文传输的HTTP请求都是不安全的,所以上述的利用HttpsRedirectionMiddleware中间件在服务端回复一个307响应将客户端重定向到HTTPS终结点的解决方案并没有真正的解决问题,因为浏览器后续还是有可能持续发送HTTP请求。虽然HTTP是无状态的传输协议,但是浏览器可以有“记忆”。如果能够让应用以响应报头的形式告诉浏览器:在未来一段时间内针对当前域名的后续请求都应该采用HTTPS,浏览器将此信息保存下来,即使用户输入的是HTTP地址,那么它也采用HTTPS的方式与服务端进行交互。

其实这就是HSTS(HTTP Strict Transport Security)的意图。HSTS可能是所有HTTP规范家族中最简单的一个了,因为整个规范只定义了上述这个用来传递HTTPS策略的响应报头,它被命名为“Strict-Transport-Security”。服务端可以利用这个报头告诉浏览器后续当前域名应该采用HTTPS进行访问,并指定采用这个策略的时间范围。如果浏览器遵循HSTS协议,那么针对同一站点的后续请求将全部采用HTTPS传输,具体流程如图4所示。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图4 采用HSTS协议

HSTS涉及的这个 “Strict-Transport-Security”响应报头可以借助HstsMiddleware中间件进行发送。对于前面演示的实例来说,我们可以按照如下的方式调用UseHsts扩展方法注册这个中间件。

...
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel (kestrel =>
{
    kestrel.Listen(IPAddress.Any, 80);
    kestrel.Listen(IPAddress.Any, 443, listener => listener.UseHttps(https => https.ServerCertificateSelector = SelelctCertificate));
});
builder.Services.AddHttpsRedirection(options => options.HttpsPort = 443);

var app = builder.Build();
app
    .UseHttpsRedirection()
    .UseHsts();
app.MapGet("/{foobar?}", (HttpRequest request) => request.Scheme);
app.Run();
...

当我们启动改动后的演示程序之后,针对“artech.com”的第一个HTTP请求依然会被正常发送出去。服务端注册的HttpsRedirectionMiddleware中间件会将请求重定向到对应的HTTPS终结点,此时UseHsts中间件会在响应中添加 如下所示的“Strict-Transport-Security”报头(S2403)。

HTTP/1.1 200 OK
Date: Sun, 19 Sep 2021 12:59:37 GMT
Server: Kestrel
Strict-Transport-Security: max-age=2592000
Content-Length: 5

https

上述的“Strict-Transport-Security”报头利用max-age属性将采用HTTPS策略的有效时间设置成2592000秒(一个月)。这是一个“滑动时间”,浏览器每次在接收到携带此报头的响应之后都会将有效截止时间设置到一个月之后,这意味着对于经常访问的站点来说,HTTPS策略将将永不过期。

浏览器会对此规则进行持久化存储,后续针对“artech.com”域名的请求将一直采用HTTPS传输方式。对于Chrome浏览器来说,其内部依然采用客户端重定向的方式实现从HTTP到HTTPS终结点的切换。具体来说,如果用户指定的是HTTP地址,Chrome会在内部生成一个指向HTTPS终结点的307重定向响应,所以我们利用Chrome提供的网络监测工具看到的还是如图25-5所示的两次报文交换,但是第一个请求并未被真的发送出去。这个内部生成的307响应携带会这个值为“HSTS”的Non-Authoritative-Reason报头。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图5 Chrome通过内部生成一个307响应实现HTTPS重定向

Chrome提供了专门的页面来查看和管理针对某个域名的HSTS设置,我们只需要在地址栏里输入“chrome://net-internals/#hsts”这个URL就可以进入这个针对HSTS/PKP(Public Key Pinning)的域名安全策略管理页面。我们可以在该页面中查询、添加和删除针对某个域名的HSTS安全策略。针对artech.com这个域名的安全策略显示在图6中。

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图6 某个域名的安全策略

[S2404]设置HSTS配置选项

到目前为止,我们利用HttpsRedirectionMiddleware中间件将HTTP请求重定向到HTTPS终结点,在利用HstsMiddleware中间件通过在响应中添加Strict-Transport-Security报头告诉客户端后续请求也应该采用HTTPS传输协议,貌似已经很完美地解决我们面临的安全问题。但是不要忘了,第一个请求采用的依旧是HTTP协议,黑客依旧可能劫持该请求并将用户重定向到钓鱼网站。

为了让浏览器针对某个域名发出的第一个请求也无条件采用HTTPS传输方式,我们必须在全网范围内维护一个统一的域名列表。当浏览器在安装的时候会将这个列表保存在本地,并在每次启动的时候预加载此列表,所以我们称这个域名列表为“HSTS Preload List”。如果需要将某个域名添加到HSTS预加载列表中,我们可以利用https://hstspreload.org站点提交申请,

ASP.NET Core 6框架揭秘实例演示[36]:HTTPS重定向-LMLPHP

图25-7 HSTS预加载列表提交官网

通过图25-7所示的这个站点提交的预加载域名列表最初专供Chrome使用的,但是目前大部分主流浏览器(Firefox, Opera, Safari, IE 11 和Edge)也都会使用这个列表。也正式因为这个列表会被广泛地使用,官方会对我们提交的域名进行严格的审核,并且审核期期还不短(一到两个月)。审核通过后,提交的域名还不会立即生效,还要等到新版本的浏览器发布的时候。有资质的站点必须满足如下几个条件:

  • 拥有一张有效的证书。
  • 对于采用80端口的HTTP终结点,必须存在对应的采用相同主机名称(域名)的HTTPS终结点。
  • 所有子域名均支持HTTPS。
  • 对于针对基础域名(Base Domain)的HTTPS请求,接收到的响应必须包含“Strict-Transport-Security”这个HSTS报头,并且该报头内容满足如下条件:
    • max-age属性代表的有效时间在一年(含一年)以上,即大于31536000秒;
    • 包含includeSubDomains指令,该指令表示HSTS策略会应用到所有的子域名上;
    • 必须包含preload指令。
    • 如果需要对HTTPS请求实施重定向,重定向的响应本身也必须包含这样的HSTS报头。

从上面这个列表可以看出,HSTS涉及的“Strict-Transport-Security”响应报头除了包含必需的表示有效期限的max-age属性之外,还包含includeSubDomains和preload两个指令。它们都定义在对应的HstsOptions配置选项中,我们可以按照如下的方式调用AddHsts扩展方法并利用指定的Action<HstsOptions>委托进行设置。如下的演示程序对HstsOptions配置选项的四个属性进行了设置。

...
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel(kestrel =>
{
    kestrel.Listen(IPAddress.Any, 80);
    kestrel.Listen(IPAddress.Any, 443, listener => listener.UseHttps(
       https => https.ServerCertificateSelector = SelelctCertificate));
});

builder.Services.AddHttpsRedirection(options => options.HttpsPort = 443);
builder.Services.AddHsts(options => {
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = true;
    options.ExcludedHosts.Add("foobar.com");
 });

var app = builder.Build();
app
    .UseHttpsRedirection()
    .UseHsts();
app.MapGet("/{foobar?}", (HttpRequest request) => request.Scheme);
app.Run();
...

由上面这个应用返回的响应都将包含如下这个HSTS报头。由于includeSubDomains指令的存在,如果之前发生过针对artech.com域名的请求,那么针对其子域名blog.artech.com的请求也将自动切换到HTTPS传输方式。虽然具有preload指令,但是我们的站点并不能添加到HSTS预加载列表中,所以此设定起不到任何作用。由于域名 “foobar.com” 被显式地排除在HSTS站点之外,浏览器不会将针对它的HTTP请求转换成HTTPS传输方式,由于注册了HttpsRedirectionMiddleware中间件,HTTP请求还是会以客户端重定向的方式切换到对应的HTTPS终结点。

strict-transport-security: max-age=31536000; includeSubDomains; preload
06-01 10:14