ASP.NET Core API 的授權風險,常常不是規則寫錯,而是新 action 根本沒有進入任何規則。只在少數 controller 貼上 [Authorize],等於把安全性建立在開發者每次都記得加 attribute 的假設上;新增端點、抽取基底類別或複製 action 時,只要漏一次,就可能形成未預期的公開路由。更穩定的設計是先把 MVC API 設成預設拒絕匿名,再以小範圍 [AllowAnonymous] 宣告必要公開端點,同時把基底 controller 的 helper 改成 protected 或 [NonAction]。這套做法不是靠程式碼看起來安全,而是用 HTTP 狀態碼、路由可達性與端點清冊建立可重現證據。

ASP.NET Core API 為何需要預設授權

直接答案:ASP.NET Core API 預設授權不可只靠驗證機制;單獨設定驗證機制不會自動限制 controller;若 action 沒有授權 metadata,也沒有全域規則,它可能直接接受匿名要求。最安全的基線是讓新 action 預設受保護,再逐一開放真正需要公開的入口。

驗證身分與決定是否允許存取,是兩個不同步驟。應用程式即使已註冊 authentication handler,也不代表每個 MVC action 都會要求已驗證使用者。[Authorize]、policy、全域 filter 或 fallback policy 才會建立授權要求。若專案只依賴 controller 作者手動貼 attribute,安全邊界就會隨著程式碼成長而漂移。

這類缺口特別容易出現在大型 API。團隊可能先為登入後頁面建立數十個 controller,之後新增匯出、查詢、維護與背景整合端點。每個 action 單獨看都合理,但缺少一份「沒有任何標記時應該發生什麼」的共同答案。當預設值是允許,漏標就是公開;當預設值是拒絕,漏標通常只會在測試中得到 401,問題會較早被看見。

預設授權也不能只看瀏覽器畫面。前端頁面隱藏按鈕、路由 guard 或選單權限,都不會取代伺服器端限制。可靠檢查必須直接對 API 發出匿名要求,確認未標記 action 觸發預期的 authentication challenge(本文沙箱為 401),而不是只確認 UI 沒有顯示入口。

本文要解決的問題是「如何為 ASP.NET Core controller API 建立安全預設」,與單一登入格式、claims 內容或某個 policy handler 的實作不同。重點不在驗證資料如何產生,而在新端點沒有額外標記時,框架究竟採取允許還是拒絕。

全域 AuthorizeFilter 與 FallbackPolicy 怎麼選

直接答案:若目標是 MVC 授權管線,可用全域 AuthorizeFilter 建立共同要求;若整個端點路由系統都應預設要求已驗證使用者,官方較偏好 fallback policy。選擇前必須畫出靜態檔案、Razor Pages、Minimal APIs 與 controllers 的實際中介軟體邊界。

Microsoft 文件把 fallback policy 定義為:當 endpoint 沒有產生其他授權 policy 時所採用的規則。這適合「大多數或全部伺服器端端點都應受保護」的應用程式,因為新增 endpoint 而忘記 attribute 時,仍會落入安全基線。相較之下,AuthorizeFilter 屬於 MVC 的授權 filter;全域 MVC options 也可能影響同一應用程式的 Razor Pages,不能把 AddControllers 註冊寫法當成只影響 API 的保證。它不會自動保護 Minimal APIs。

兩者不是只差寫法。fallback policy 由 authorization middleware 執行,是否碰到靜態檔案或沒有配對 endpoint 的要求,會受到 middleware 順序影響。若靜態檔案在 authorization middleware 之前處理,通常不受 fallback policy 保護;若架構不同,公開頁面依賴的資產也可能需要明確檢查。不能把「某份範例可行」直接推論成所有 hosting pipeline 都相同。

以下僅是只有 controllers 的沙箱設定節錄,不是正式身分驗證實作;完整 DemoAuthenticationHandler 與專案檔見驗證章節。若應用程式混用 MVC 頁面或 Razor Pages,必須另驗證全域 filter 對它們的影響:

builder.Services.AddAuthentication("Demo")
    .AddScheme("Demo", _ => { });
builder.Services.AddAuthorization();

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder("Demo")
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

實際專案不應只複製這段程式。還要確認 UseAuthentication()、UseAuthorization() 與 endpoint mapping 的順序,列出所有非 controller 端點,並測試靜態資源與公開頁面。若系統同時有 Minimal APIs,且它們也應預設受保護,fallback policy 往往比較一致。若要只改一組 API controllers,可在共同基底 controller 套用授權要求並驗證繼承效果;不要假設全域 filter 天然只涵蓋該組 API。

決策原則可以濃縮成一句話:先定義要保護的資源集合,再選能完整覆蓋該集合的機制。不要反過來先選 API,再假設所有資源自然被涵蓋。

AllowAnonymous 必須是小範圍例外

直接答案:[AllowAnonymous] 會略過 MVC 的授權要求,因此它不是裝飾性標記,而是一項公開介面決策。應只放在必要 action,記錄公開理由、輸入限制與濫用防護,避免套在整個 controller 造成未來 action 自動公開。

公開端點確實存在,例如健康狀態、登入入口、公開內容或由外部系統回呼的介面。但「業務流程需要未登入使用者」不代表該 action 可以沒有其他限制。每一個 [AllowAnonymous] 都應回答:為何需要公開、允許哪些 HTTP method、接受哪些欄位、是否有流量限制、回應是否只包含最小資料、錯誤處理會不會洩漏內部細節。

Microsoft 的 MVC 文件明確指出,[AllowAnonymous] 會讓同一 controller 或 action 上的授權敘述失效。這也是不建議隨意貼在 controller class 的原因。今天 class 可能只有一個公開 action,三個月後有人在同一 class 新增管理功能,新 action 會繼承匿名例外,而 code review 若只看新方法,可能忽略 class 層級 metadata。

較好的模式是把公開 action 分組到用途單純的 controller,並把 [AllowAnonymous] 放在 action 或高度專用的 class。搭配端點清冊與自動測試,任何新增公開路由都會成為顯式差異。測試也不能只驗證公開 action 回 200;還要驗證附近的未標記 action 仍回 401,避免例外範圍擴大。

公開例外也需要生命週期。可以在合併要求中加入 owner、理由與重新檢查日期;若呼叫端已能提供身分,應移除例外。這不是追求零個公開 action,而是讓每個公開 action 都有可解釋、可測試、可撤回的理由。

基底 Controller 的 Public Helper 為何可能變成 Action

直接答案:ASP.NET Core MVC 會把 controller 上的 public method 視為 action,除非方法標記 [NonAction]。基底 controller 的共用 helper 若保持 public,衍生 controller 可能取得額外可路由方法;應優先改為 protected,必要時再用 [NonAction] 明確排除。

基底 controller 常被用來集中取得目前使用者、建立共用回應、檢查輸入或組合查詢條件。問題在於開發者可能把 helper 寫成 public,只因為其他 class 需要呼叫,卻忽略 MVC action discovery 也會掃描 public method。當衍生 controller 使用包含 [action] 的 route template 時,helper 名稱甚至可能直接出現在 URL。

官方 controller 文件給出的規則很直接:public method 預設是 action,[NonAction] 才能排除。這條規則不應靠記憶維護。設計上應讓 helper 具有最小可見性:只供衍生 controller 使用就設為 protected;完全不需要 controller context 的邏輯則移到一般 service;只有因相容性必須保持 public 時,才加 [NonAction] 並建立路由測試。

[NonAction] 的價值不是讓危險方法「藏起來」,而是把 MVC metadata 與一般物件方法分開。若 helper 本身會執行高影響操作,把它留在 controller base class 仍會增加耦合與誤用風險。較佳架構是 controller 保持薄層,商業規則交給注入 service,基底類別只留下真正與 HTTP 表達有關的輕量功能。

盤點時不要只搜尋 [HttpGet] 或 [HttpPost]。慣例路由與 controller-level route 可能讓沒有 method attribute 的 public method 成為 action。應從實際 endpoint data source、OpenAPI 文件或整合測試取得清冊,再對照程式碼預期。路由是否存在,必須以執行期證據確認。

驗證與重現:用 HTTP 狀態碼證明授權邊界

直接答案:在隔離本機啟動最小 ASP.NET Core 應用程式,分別測試未標記 action、[AllowAnonymous] action、基底 public helper 與 [NonAction] helper。五項通過條件依序為 401、200、200、200 與 404,而不是只確認專案能建置。

本次實際使用 .NET SDK 10.0.400 建置與執行具名沙箱,執行期為 .NET 10.0.11。沙箱只使用記憶體中的示範身分與本機隨機連接埠,不連接資料庫、外部服務或真實帳號。驗證程式由同一 process 啟動 Kestrel,再以 HttpClient 發出五個要求,因此不需要手動操作瀏覽器。

  1. 在新的隔離目錄建立 verification/aspnetcore_global_authorization_20261009,將下方兩份完整檔案存入該子目錄,再從隔離目錄執行命令。先確認 SDK;本次記錄為 10.0.400,專案檔固定的是 net10.0 目標框架,而非 SDK 版本。若使用不同 SDK 或 runtime,請記錄版本並重跑,不要直接沿用本次結果:
dotnet --version
dotnet run --project verification/aspnetcore_global_authorization_20261009/aspnetcore_global_authorization_20261009.csproj --configuration Release
  1. 檢查沒有 [AllowAnonymous] 的 Ping。匿名要求應回 401;加入沙箱示範身分 header 後應回 200。若匿名要求得到 200,代表全域 filter 沒有建立預期基線,測試必須失敗。

  2. 檢查明確標記 [AllowAnonymous] 的 PublicInfo。匿名要求應回 200,證明公開例外仍可用;同時這也證明不是整個伺服器失去回應,而是 metadata 造成不同結果。

  3. 檢查基底 class 的 ExposedHelper。加入示範身分後應回 200,證明繼承的 public helper 會被 action discovery 找到。這一步刻意重現風險,不是推薦設計。

  4. 檢查標記 [NonAction] 的 HiddenHelper。即使提供示範身分,也應回 404,證明方法沒有形成 endpoint。在本沙箱中,任何非 404 都使斷言失敗;但其他應用程式若有 fallback policy 或自訂 middleware,沒有配對端點也可能回 401,因此不能只憑 401 推論 helper 一定形成路由。

實際執行結果為 SUMMARY 5/5 checks passed on .NET 10.0.11。五項結果依序是匿名未標記 action 回 401、已驗證要求回 200、公開 action 回 200、基底 public helper 回 200、[NonAction] helper 回 404。這些結果證明沙箱中的 MVC 行為;實際專案仍需依自己的 route template、authentication scheme、filter 順序與 hosting pipeline 重跑整合測試。

完整隔離沙箱檔案

僅限本機測試。 Demo handler 直接信任任意非空 X-Demo-User header,沒有驗證真實身分,絕不可部署到正式環境。程式只綁定 loopback 隨機連接埠。此測試涵蓋全域 MVC filter;沒有測試 fallback policy、Razor Pages、角色或資源層級授權。

專案檔 aspnetcore_global_authorization_20261009.csproj:


  
    net10.0
    enable
    enable
  

Program.cs:

using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();

builder.Services
    .AddAuthentication("Demo")
    .AddScheme("Demo", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder("Demo")
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

await app.StartAsync();
var server = app.Services.GetRequiredService();
var address = server.Features.Get()?.Addresses.Single()
    ?? throw new InvalidOperationException("Kestrel did not expose a bound address.");

using var client = new HttpClient { BaseAddress = new Uri(address) };
var checks = new List();

checks.Add((
    "unannotated action is denied to an anonymous request",
    (await client.GetAsync("/api/Sample/Ping")).StatusCode,
    HttpStatusCode.Unauthorized));

var authenticatedPing = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/Ping");
authenticatedPing.Headers.Add("X-Demo-User", "reader");
checks.Add((
    "unannotated action accepts an authenticated request",
    (await client.SendAsync(authenticatedPing)).StatusCode,
    HttpStatusCode.OK));

checks.Add((
    "AllowAnonymous action remains intentionally public",
    (await client.GetAsync("/api/Sample/PublicInfo")).StatusCode,
    HttpStatusCode.OK));

var exposedHelper = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/ExposedHelper");
exposedHelper.Headers.Add("X-Demo-User", "reader");
checks.Add((
    "inherited public helper is discovered as an action",
    (await client.SendAsync(exposedHelper)).StatusCode,
    HttpStatusCode.OK));

var hiddenHelper = new HttpRequestMessage(HttpMethod.Get, "/api/Sample/HiddenHelper");
hiddenHelper.Headers.Add("X-Demo-User", "reader");
checks.Add((
    "NonAction helper is not routable",
    (await client.SendAsync(hiddenHelper)).StatusCode,
    HttpStatusCode.NotFound));

var passed = 0;
foreach (var check in checks)
{
    if (check.Actual == check.Expected)
    {
        passed++;
        Console.WriteLine($"PASS {check.Name}: {(int)check.Actual}");
    }
    else
    {
        Console.WriteLine($"FAIL {check.Name}: expected {(int)check.Expected}, got {(int)check.Actual}");
    }
}

Console.WriteLine($"SUMMARY {passed}/{checks.Count} checks passed on .NET {Environment.Version}");
await app.StopAsync();
return passed == checks.Count ? 0 : 1;

public sealed class DemoAuthenticationHandler : AuthenticationHandler
{
    public DemoAuthenticationHandler(
        IOptionsMonitor options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder)
    {
    }

    protected override Task HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Demo-User", out var user) || string.IsNullOrWhiteSpace(user))
        {
            return Task.FromResult(AuthenticateResult.NoResult());
        }

        var identity = new ClaimsIdentity(
            new[] { new Claim(ClaimTypes.Name, user.ToString()) },
            Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);
        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

public abstract class ApiControllerBase : ControllerBase
{
    public IActionResult ExposedHelper() => Ok(new { result = "base helper" });

    [NonAction]
    public IActionResult HiddenHelper() => Ok(new { result = "hidden helper" });
}

[ApiController]
[Route("api/[controller]/[action]")]
public sealed class SampleController : ApiControllerBase
{
    [HttpGet]
    public IActionResult Ping() => Ok(new { result = "protected" });

    [AllowAnonymous]
    [HttpGet]
    public IActionResult PublicInfo() => Ok(new { result = "public" });
}

預期輸出(已於本次環境重跑):

PASS unannotated action is denied to an anonymous request: 401
PASS unannotated action accepts an authenticated request: 200
PASS AllowAnonymous action remains intentionally public: 200
PASS inherited public helper is discovered as an action: 200
PASS NonAction helper is not routable: 404
SUMMARY 5/5 checks passed on .NET 10.0.11

全部斷言符合預期時 exit code 為 0,包括預期的拒絕結果;任一狀態不符則非零。401 是此 Demo scheme 的 challenge 結果,不是所有 cookie 或自訂 scheme 的必然回應。僅要求已驗證使用者不等於已完成角色、租戶或資源層級授權。

上線前授權稽核清單

直接答案:發布前應同時檢查預設 policy、公開例外、action discovery、呼叫端的 401 行為與端點清冊差異。只確認成功路徑會漏掉匿名可達性、helper 路由與錯誤被當成空資料等真正風險。

可將下列項目設成 code review 與 release gate:

  • 新 controller 或 action 在沒有額外 attribute 時,匿名要求是否預設得到 401?
  • 專案選擇 fallback policy 或全域 MVC filter 的理由,是否與資源範圍一致?
  • static files、Razor Pages、Minimal APIs 與 controllers 是否各自有明確規則?
  • 每個 [AllowAnonymous] 是否只套在最小範圍,並記錄公開理由?
  • 公開 action 是否限制 method、輸入大小、回應欄位與呼叫頻率?
  • controller 與所有基底 class 是否存在不必要的 public helper?
  • helper 是否能改成 protected、移到 service,或至少標記 [NonAction]?
  • 呼叫端遇到 401 時,是停止並顯示錯誤,還是把錯誤誤當成空資料繼續執行?
  • endpoint 清冊是否在變更前後做差異比較,新增公開路由是否會讓測試失敗?
  • 測試是否直接發出匿名要求,而不是只驗證已登入畫面?

監控指標也要對準邊界,包括各 route 的 401/403 比率、公開 action 的流量變化、新增 endpoint 數量、匿名成功回應數量,以及 endpoint 清冊與允許清單的差異。這些指標不能取代授權測試,但能在部署後快速指出規則是否誤擋合法呼叫,或某個新路由是否出現異常匿名流量。

最終原則是:公開必須是例外,保護必須是預設,路由必須以執行結果證明。 全域規則降低漏標風險,[AllowAnonymous] 提供可稽核的公開出口,protected 與 [NonAction] 阻止 helper 被誤當 action。三者配合端點清冊與 HTTP 整合測試,才能把 ASP.NET Core API 授權從「大家應該記得」改成「新增程式碼自然落在安全基線」。

官方查核依據: