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 發出五個要求,因此不需要手動操作瀏覽器。
- 在新的隔離目錄建立
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
檢查沒有
[AllowAnonymous]的Ping。匿名要求應回 401;加入沙箱示範身分 header 後應回 200。若匿名要求得到 200,代表全域 filter 沒有建立預期基線,測試必須失敗。檢查明確標記
[AllowAnonymous]的PublicInfo。匿名要求應回 200,證明公開例外仍可用;同時這也證明不是整個伺服器失去回應,而是 metadata 造成不同結果。檢查基底 class 的
ExposedHelper。加入示範身分後應回 200,證明繼承的 public helper 會被 action discovery 找到。這一步刻意重現風險,不是推薦設計。檢查標記
[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 授權從「大家應該記得」改成「新增程式碼自然落在安全基線」。
官方查核依據: