ASP.NET Core Minimal API 在我的日常應用愈來愈廣泛,最近有個構想是前端寫成純 HTML (類 SPA 概念,但拆分多個 HTML 以簡化複雜度) 呼叫 Minimal API 完成作業,因為是內部系統,身分證驗部分就走 Windows 驗證用 AD 帳號登入。另外,雖然被攻擊風險不如對外網站高,我還是決定加上 CSRF 防護,反正學會技巧,遲早有用到的一天。

先用 dotnet new web -o spa-minapi-anti-csrf 建立範例專案,要使用 Windows 驗證需 dotnet add package Microsoft.AspNetCore.Authentication.Negotiate 參照 Windows 驗證程式庫。在 Program.cs 用 UseStaticFiles() 加入 wwwroot 靜態目錄(裡面會放一個 index.html 當主要介面)、設定登入及授權(參考),最後用 MapGet('/ajax') 提供一個傳回登入身分及隨機產生 GUID 的簡單 WebAPI:

using Microsoft.AspNetCore.Authentication.Negotiate;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
        .AddNegotiate();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapPost("/ajax", (HttpContext ctx) => 
    (ctx.User.Identity?.IsAuthenticated == true? 
        ctx.User.Identity.Name : "Anonymous") + ":" +
    Guid.NewGuid().ToString())
    .RequireAuthorization();

app.Run();

wwwroot\index.html 內容如下:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Home</title>
</head>

<body>
    <button onclick="callAjax()">Get GUID by AJAX</button>
    <script>
        function callAjax() {
            fetch('/ajax', { method: 'POST' })
                .then(response => response.ok ? response.text() : response.statusText)
                .then(data => alert(data));
        }
    </script>
</body>

</html>

若一切順利,按鈕後會看到登入身分及 GUID。

如同之前的ASP.NET MVC 展示,這種寫法有個資安漏洞,有心人士可以在第三方網頁部署陷阱,誘騙使用者觸發這個 AJAX 呼叫:

<!DOCTYPE html>
<html>
    <body>
        <form action="http://localhost:5177/ajax" target="result" method="post">
            <button>點我拿好康</button>
        </form>
        <iframe name="result" style="width: 250px; height: 60px"></iframe>
    </body>
</html>

接下來示範如何為這種 SPA 架構加上 CSRF 防護。

針對 CSRF 攻擊,ASP.NET Core 已有相關內建機制,MS Learn 的這篇防止 ASP.NET Core中的跨網站偽造要求 (XSRF/CSRF) 攻擊包含了原理到做法的說明,是不錯的入門。

若你使用的是 MVC、RazorPage,ASP.NET Core 有現成的 Html.AntiForgeryToken() Helper 方法、[ValidateAntiForgeryToken] Attribute 可用,做法相對簡單。

這次的做法屬於 SPA 形式式,則需使用 IAntiforgery.GetAndStoreTokens(context) 產生成對 Token,其中一個為 CookieToken (名為 .AspNetCore.Antiforgery.XXXX) 會自動存成 HttpOnly Cookie,瀏覽器將自動保存並附在後續的每次請求中;另一個 RequetToken,則可設定非 HttpOnly Cookie 傳回,由 JavaScript 讀取保存,之後發 AJAX 請求時以 Request Header 方式附上。伺服器端將同時檢查 CookieToken (來自 Cookie) 及 RequestToken (來自 Request Header),二者必須相符,且 Token 中記載的 Claim 需與當時登入身分 (User.Identity) 一致才算有效。參考

由於 Minimal API 無法用 ValidateAntiForgeryToken, AutoValidateAntiforgeryToken, IgnoreAntiforgeryToken 等 Filter,官方文件有示範實作 MapPost().ValidateAntiforgery() 擴充方法加上檢查,但它依賴 ASP.NET Core 7.0+ 新加入 API。在 .NET 6 我想到的解法是比照之前檢查 API Key 的做法,寫一段 Middleware 邏輯加入自訂檢查。

Program.cs 改寫如下:

using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// 註冊 IAntiforgery 服務
builder.Services.AddAntiforgery(o => o.HeaderName = "X-XSRF-TOKEN");

builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
        .AddNegotiate();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.UseDefaultFiles();
app.UseStaticFiles();

app.Use(async (context, next) =>
{
    // TODO: 此處用 /api 字首隨意示範,實務上應加入自訂邏輯,針對需要防止 CSRF 的動作進行檢查
    if (context.Request.Path.StartsWithSegments("/api"))
    {
        try
        {
            var antiForgeryService = context.RequestServices.GetRequiredService<IAntiforgery>();
            await antiForgeryService.ValidateRequestAsync(context.Request.HttpContext);
            await next.Invoke();
        }
        catch (AntiforgeryValidationException)
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Antiforgery token validation failed.");
        }
    }
    else await next.Invoke();
});

// 註冊 /antiforgery/token 方法寫入 X-XSRF-TOKEN Cookie
app.MapPost("/antiforgery/token", ([FromServices] IAntiforgery forgeryService, HttpContext context) =>
{
    // 為這個 Request 產生 AntiforgeryTokenSet,在 Response 加入 CookieToken,
    // 並將 Response 的 Cache-control/Pragma Header 設成 "no-cache" 
    // 另外也會將 X-Frame-Options Header 設成 SAMEORIGIN 以防止被外部網站內嵌
    var tokens = forgeryService.GetAndStoreTokens(context);
    // 將 AntiforgeryTokenSet 中的 RequestToken 設成 Cookie,並指定 HttpOnly 為 false,允許 JavaScript 取出作為 Request Header
    // 註:瀏覽器會自動傳送 Cookie,使用 Header 傳送安全性較高
    context.Response.Cookies.Append(tokens.HeaderName!, tokens.RequestToken!, new CookieOptions { HttpOnly = false });
    return Results.Ok();
})
    // RequestToken 會包含登入身分 Claim,必須符合驗證時的登入身分
    // 若 API 需登入使用,則產生 Token 時也必須要求登入
    .RequireAuthorization(); 

app.MapPost("/api/ajax", (HttpContext ctx) => 
    (ctx.User.Identity?.IsAuthenticated == true? 
        ctx.User.Identity.Name : "Anonymous") + ":" +
    Guid.NewGuid().ToString())
    .RequireAuthorization();

app.Run();

// 官方文件提供的 ValidateAntiforgery() 擴充方法,ASP.NET Core 7.0+ 支援 AddEndpointFilter
/*
internal static class AntiForgeryExtensions
{
    public static TBuilder ValidateAntiforgery<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder
    {
        return builder.AddEndpointFilter(routeHandlerFilter: async (context, next) =>
        {
            try
            {
                var antiForgeryService = context.HttpContext.RequestServices.GetRequiredService<IAntiforgery>();
                await antiForgeryService.ValidateRequestAsync(context.HttpContext);
            }
            catch (AntiforgeryValidationException)
            {
                return Results.BadRequest("Antiforgery token validation failed.");
            }
            return await next(context);
        });
    }
}
*/

index.html 小做修改,一開始先呼叫 /antiforgery/token,取出 RequestToken,之後發送 AJAX 請求時設成 Request Header X-XSRF-TOKEN:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Home</title>
</head>

<body>
    <button onclick="callAjax()">Get GUID by AJAX</button>
    <script>
        // 取得 XSRF-TOKEN
        let xsrfToken;
        fetch("/antiforgery/token", { method: "POST" })
            .then(res => {
                xsrfToken = document.cookie
                    .split("; ")
                    .find(row => row.startsWith("X-XSRF-TOKEN="))
                    .split("=")[1];
            });

        function callAjax() {
            fetch('/api/ajax', { method: 'POST', headers: { 'X-XSRF-TOKEN': xsrfToken } })
                .then(response => response.ok ? response.text() : response.statusText)
                .then(data => alert(data));
        }
    </script>
</body>

</html>

加上防護後,攻擊者就無法透過跨端台或本機 HTML 網站誘騙使用者進行呼叫。

最後,實地觀察 /antiforgery/token 的回傳結果證驗理論:

如上圖,共寫入兩個 Cookie,第一個名為 .AspNetCore.Antiforery.j66w7ZNqXeA [1] 為 CookieToken,設定 samesite=strict 及 httponly [2];第二個 X-XSRF-TOKEN [3] 是我們用 Cookies.Add 自己加的,不含 httponly 旗標,JavaScript 可讀取。另外,GetAndStoreTokens() 說明有提到會自動加上 X-Frame-Options SAMEORIGIN [4],也在觀察中得到印證。

【延伸閱讀】

Example of how to implement anti CSRF mechanism in SPA + ASP.NET Core Minimal API.


Comments

# by Ho.Chun

請問在 Web 中,說要”走 Windows 驗證”,是什麼意思 🤔 假設 UserA 登入 ComputerA,並打開瀏覽器 http://ComputerB/Site/index.html UserB 登入 ComputerB,建立ㄧ個 AppPool,並將識別設定為 UserC,這個 AppPool 有ㄧ個 Site,說要”走 Windows 驗證” 請問 此時的 Windows 驗證,會回傳誰登入 ?

# by Jeffrey

to Ho.Chun, 「網站走 Windows 驗證」算是我平日作業的口頭用語(黑話? :P),其正式術語為 [整合式 Windows 驗證]( https://learn.microsoft.com/zh-tw/aspnet/web-api/overview/security/integrated-windows-authentication )。 使用者連上使用 整合式 Windows 驗證 的網站,瀏覽器會跳出帳號密碼對話框(或使用登入 Windows 的帳號自動登入),通過身分驗證後,ASP.NET 程式從 User.Identity.Name 可讀取到登入的 AD 帳號或 Windows 本機帳號,以此判定使用者是誰,決定可以存取哪些資源。而 ASP.NET 程式讀取伺服器上檔案等資源用的則是 AppPool 的識別身分,IIS 7.5 + 建議用 IIS AppPool\AppPoolName 專屬虛擬帳號,權限很小,以降低網站不幸被入侵時的危害。 整合式 Windows 驗證又可分為 Kerberos 及 NTLM,補充幾篇相關研究: * [關於IIS整合式Windows驗證的冷知識]( https://blog.darkthread.net/blog/ntlm-and-kerberos-on-iis/ ) * [Windows驗證歷程觀察與Kerberos/NTLM判別]( https://blog.darkthread.net/blog/check-auth-method-of-browser/ ) * [設定 Chrome/Edge 自動登入 Windows 驗證網站]( https://blog.darkthread.net/blog/chrome-auto-login-win-auth-iis/ )

# by Cash

如果禁止前端不能讀 cookie 的話,那就只能由 api 返回 token 了 XD

# by Toolman

黑大好 想請問 api try catch 驗證 xsrf token 那段 用 if else 替代 try catch 會不會比較好呢? 根據我自己的經驗,流程控制用 try catch 的方式,滿容易拖垮系統效能的 尤其量大的時候,速度可能會差到指數倍

# by Ho.Chun

想確認一下 "瀏覽器會跳出帳號密碼對話框(或使用登入 Windows 的帳號自動登入)" 這邊的帳密,應該指的是伺服器電腦上的使用者帳密,對嗎 ? 與瀏覽器的電腦上的使用者帳密無關 ?

# by Jeffrey

to Toolman, Try Catch Block是否會影響效能? https://blog.darkthread.net/blog/try-catch-performance/ 只有出現 Exception 進入 catch 段才會有效能差異,而 ValidateRequestAsync 在驗證失敗時是以 throw 錯誤方式表現,要用這個 API 只能用 try catch 捕捉,不然就要另外自己寫檢核改傳 boolean 走 if 邏輯

# by Jeffrey

to Ho.Chun,帳號必須要被伺服器認可,可能是伺服器主機上開的帳號,也可能是AD網域帳號

# by Toolman

黑大好 感謝回復,這突然讓我想到,如果駭客想攻擊 web service 好像 ddos 隨便亂帶 csrf token 就能取得不錯的效果 (?

# by Jeffrey

to Toolman, DDoS 防護是另一個獨立議題,我個人覺得不用為了它改變做法。try catch 邏輯在 .NET 程式四處可見,只在這裡避開意義不大。例如:先傳送一個超大但有錯的 JSON Body,一方面先消耗資源解析,最後再出錯丟 Exception,攻擊效果應就勝過鎖定 CSRF Token 驗證的 Exception 了。

Post a comment