HTTPS是確保傳輸安全最主要的手段,並且已經成為了網際網路預設的傳輸協定。不知道讀者朋友們是否注意到當我們利用瀏覽器(比如Chrome)瀏覽某個公共站點的時候,如果我們輸入的是一個HTTP地址,在大部分情況下瀏覽器會自動重定向到對應HTTPS地址。這一特性源於瀏覽器和伺服器端針對HSTS(HTTP Strict Transport Security)這一HTTP規範的支援。ASP.NET利用HstsMiddleware和HttpsRedirectionMiddleware這兩個中介軟體提供了對HSTS的實現。(本文提供的範例演示已經同步到《ASP.NET Core 6框架揭祕-範例演示版》)
[S2401]構建HTTPS站點(原始碼)
[S2402]HTTPS終結點重定向(原始碼)
[S2403]註冊HstsMiddleware中介軟體(原始碼)
[S2404]設定HSTS設定選項(原始碼)
雖然目前絕大部分的公共站點都提供了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)」儲存中。
我們可以利用Certificate MMC(Microsoft Management Console)檢視生成的這三張證書。具體的做法是執行mmc命名開啟一個MMC對話方塊,並選擇選單「File>Add/Remove Snap-In...」開啟Snap-In視窗,在列表中選擇「Certificate」選項。在彈出的證書儲存型別對話方塊架中,我們選擇「Current user account」選項。在最終開啟的證書管理控制檯上,我們可以在Personal儲存節點中看到如圖25-1所示的三張證書。
圖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)」的警告。
圖2 存取HTTP和HTTPS終結點
從安全的角度來講,我們肯定是希望使用者的每個請求指向的都是HTTPS終結點,但是我們不可能要求使用者在位址列輸入的URL都以「https」作為字首,這個問題可以通過伺服器端以重定向的方式來解決。如圖25-3所示,如果伺服器端接收到一個HTTP請求,它立即回覆一個狀態碼為307的臨時重定向響應,並將重定向地址指向對應的HTTPS終結點,那麼瀏覽器會自動對新的HTTPS終結點重新發起請求。
圖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://artech.com/foobar
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
按照目前網際網路的安全標準來看,以明文傳輸的HTTP請求都是不安全的,所以上述的利用HttpsRedirectionMiddleware中介軟體在伺服器端回覆一個307響應將使用者端重定向到HTTPS終結點的解決方案並沒有真正的解決問題,因為瀏覽器後續還是有可能持續傳送HTTP請求。雖然HTTP是無狀態的傳輸協定,但是瀏覽器可以有「記憶」。如果能夠讓應用以響應報頭的形式告訴瀏覽器:在未來一段時間內針對當前域名的後續請求都應該採用HTTPS,瀏覽器將此資訊儲存下來,即使使用者輸入的是HTTP地址,那麼它也採用HTTPS的方式與伺服器端進行互動。
其實這就是HSTS(HTTP Strict Transport Security)的意圖。HSTS可能是所有HTTP規範家族中最簡單的一個了,因為整個規範只定義了上述這個用來傳遞HTTPS策略的響應報頭,它被命名為「Strict-Transport-Security」。伺服器端可以利用這個報頭告訴瀏覽器後續當前域名應該採用HTTPS進行存取,並指定採用這個策略的時間範圍。如果瀏覽器遵循HSTS協定,那麼針對同一站點的後續請求將全部採用HTTPS傳輸,具體流程如圖4所示。
圖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報頭。
圖5 Chrome通過內部生成一個307響應實現HTTPS重定向
Chrome提供了專門的頁面來檢視和管理針對某個域名的HSTS設定,我們只需要在位址列裡輸入「chrome://net-internals/#hsts」這個URL就可以進入這個針對HSTS/PKP(Public Key Pinning)的域名安全策略管理頁面。我們可以在該頁面中查詢、新增和刪除針對某個域名的HSTS安全策略。針對artech.com這個域名的安全策略顯示在圖6中。
圖6 某個域名的安全策略
到目前為止,我們利用HttpsRedirectionMiddleware中介軟體將HTTP請求重定向到HTTPS終結點,在利用HstsMiddleware中介軟體通過在響應中新增Strict-Transport-Security報頭告訴使用者端後續請求也應該採用HTTPS傳輸協定,貌似已經很完美地解決我們面臨的安全問題。但是不要忘了,第一個請求採用的依舊是HTTP協定,駭客依舊可能劫持該請求並將使用者重定向到釣魚網站。
為了讓瀏覽器針對某個域名發出的第一個請求也無條件採用HTTPS傳輸方式,我們必須在全網範圍內維護一個統一的域名列表。當瀏覽器在安裝的時候會將這個列表儲存在本地,並在每次啟動的時候預載入此列表,所以我們稱這個域名列表為「HSTS Preload List」。如果需要將某個域名新增到HSTS預載入列表中,我們可以利用https://hstspreload.org站點提交申請,
圖25-7 HSTS預載入列表提交官網
通過圖25-7所示的這個站點提交的預載入域名列表最初專供Chrome使用的,但是目前大部分主流瀏覽器(Firefox, Opera, Safari, IE 11 和Edge)也都會使用這個列表。也正式因為這個列表會被廣泛地使用,官方會對我們提交的域名進行嚴格的稽核,並且稽核期期還不短(一到兩個月)。稽核通過後,提交的域名還不會立即生效,還要等到新版本的瀏覽器釋出的時候。有資質的站點必須滿足如下幾個條件:
從上面這個列表可以看出,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