ASP.NET Core CORS 深度解析:从 AddCors 到 CSRF 防御

一、背景:SOP 与 CORS 的关系

理解 CORS 之前,必须先理解它试图解决的问题从哪里来。

1.1 SOP(Same-Origin Policy,同源策略)

浏览器内置了一条基础安全规则:来自 A 站的 JS 代码,不能读取 B 站返回的响应内容。 这条规则叫做同源策略。

"同源"要求协议、域名、端口三者完全一致:

复制代码
https://app.example.com:443   ← 基准
https://app.example.com:8080  ← 端口不同,跨域
http://app.example.com:443    ← 协议不同,跨域
https://api.example.com:443   ← 域名不同,跨域

SOP 的存在是为了防止如下攻击:

复制代码
你已登录 bank.com(浏览器持有合法 session cookie)

你访问了 evil.com
evil.com 的 JS 执行:
    fetch("https://bank.com/api/account-info", { credentials: "include" })
    .then(r => r.json())
    .then(data => sendToAttacker(data))  ← 试图读取你的银行数据

有 SOP:浏览器拦截响应,JS 读不到任何数据
没有 SOP:账户信息泄露

1.2 CORS:在 SOP 上开一个受控的口子

SOP 保护了安全,但也阻断了正常的跨域业务请求:

复制代码
前端:https://app.example.com
后端:https://api.example.com   ← 不同子域,SOP 认为是跨域

没有 CORS:前端 JS 无法读取后端响应,前后端分离架构无法工作
有  CORS:后端明确声明"我允许 app.example.com 读取我的响应"
           浏览器看到授权,放行

CORS 的本质是服务端向浏览器出示授权书,授权特定来源可以读取跨域响应。


二、AddCors:服务注册阶段

2.1 它做了什么

AddCors 是定义在 Microsoft.Extensions.DependencyInjection 命名空间下的扩展方法,本质上只做三件事:

csharp 复制代码
public static IServiceCollection AddCors(
    this IServiceCollection services,
    Action<CorsOptions> setupAction)
{
    services.AddOptions();
    services.TryAdd(ServiceDescriptor.Transient<ICorsService, CorsService>());
    services.TryAdd(ServiceDescriptor.Transient<ICorsPolicyProvider, DefaultCorsPolicyProvider>());
    services.Configure(setupAction);  // 把 CorsOptions 写入配置系统
    return services;
}
注册内容 生命周期 作用
ICorsServiceCorsService Transient 执行策略校验、写响应头的核心引擎
ICorsPolicyProviderDefaultCorsPolicyProvider Transient 按名称查找策略的提供者
CorsOptions(通过 setupAction Options 存储所有命名策略的内存字典

2.2 策略是如何存储的

setupAction 中调用的 options.AddPolicy(name, builder => {...}) 实际执行逻辑:

csharp 复制代码
// CorsOptions 内部
private readonly Dictionary<string, CorsPolicy> _policies = new();

public void AddPolicy(string name, Action<CorsPolicyBuilder> configurePolicy)
{
    var builder = new CorsPolicyBuilder();
    configurePolicy(builder);
    _policies[name] = builder.Build();  // Build() 冻结成不可变的 CorsPolicy 对象
}

CorsPolicyBuilder.Build() 返回的 CorsPolicy 是一个不可变快照

csharp 复制代码
public class CorsPolicy
{
    public IList<string> Origins { get; }         // 允许来源列表
    public IList<string> Methods { get; }         // 允许方法列表
    public IList<string> Headers { get; }         // 允许头部列表
    public IList<string> ExposedHeaders { get; }  // 暴露给 JS 的响应头
    public bool AllowCredentials { get; }
    public TimeSpan? PreflightMaxAge { get; }     // preflight 缓存时长
    public Func<string, bool> IsOriginAllowed { get; } // 动态来源校验委托
}

2.3 多策略配置示例

典型的多策略配置(来源于 appsettings.json):

csharp 复制代码
services.AddCors(options =>
{
    foreach (var kv in corsPolicies)
    {
        options.AddPolicy(kv.Key, policy =>
        {
            ConfigureOrigins(policy, kv.Value.Origins);
            ConfigureMethods(policy, kv.Value.Methods);
            ConfigureHeaders(policy, kv.Value.Headers);

            if (kv.Value.AllowCredentials)
                policy.AllowCredentials();
        });
    }
});

对应配置文件结构:

json 复制代码
"CorsPolicies": {
  "PublicApi": {
    "Origins": ["https://app.example.com"],
    "Methods": ["GET", "POST"],
    "Headers": ["Content-Type", "Authorization"],
    "AllowCredentials": true
  },
  "InternalApi": {
    "Origins": ["*"],
    "Methods": ["*"],
    "Headers": ["*"],
    "AllowCredentials": false
  }
}

这种设计将 CORS 策略外置到配置文件,避免硬编码,支持多环境灵活切换。


三、核心组件分工:ICorsServiceICorsPolicyProvider

两者职责完全分离,体现了"查找"与"执行"的解耦设计。

3.1 ICorsPolicyProvider:策略查找器

csharp 复制代码
public interface ICorsPolicyProvider
{
    Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName);
}

默认实现 DefaultCorsPolicyProvider 的逻辑极其简单:

csharp 复制代码
public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
    var options = _options.Value;

    CorsPolicy? policy;
    if (policyName != null)
        options.PolicyMap.TryGetValue(policyName, out policy);  // 按名查找
    else
        policy = options.DefaultPolicy;                          // 取默认策略

    return Task.FromResult(policy);
}

它只负责从字典里找策略 ,不做任何校验。这也意味着你可以实现自定义的 ICorsPolicyProvider,例如从数据库动态加载策略,而无需修改任何其他代码。

3.2 ICorsService:策略执行引擎

csharp 复制代码
public interface ICorsService
{
    CorsResult EvaluatePolicy(HttpContext context, CorsPolicy policy);
    void ApplyResult(CorsResult result, HttpResponse response);
}

CorsService 负责两件事:

① 校验请求是否符合策略,返回 CorsResult

csharp 复制代码
public CorsResult EvaluatePolicy(HttpContext context, CorsPolicy policy)
{
    var result = new CorsResult();
    var origin = context.Request.Headers[HeaderNames.Origin].ToString();

    if (string.IsNullOrEmpty(origin)) return result; // 无 Origin,非跨域

    EvaluateOrigin(result, policy, origin);     // 所有请求都校验 Origin

    if (IsPreflightRequest(context))
        EvaluatePreflightRequest(result, policy, context);
    else
        EvaluateRequest(result, policy, context);

    return result;
}

② 把 CorsResult 写入 HTTP 响应头:

csharp 复制代码
public void ApplyResult(CorsResult result, HttpResponse response)
{
    if (result.IsOriginAllowed)
        response.Headers["Access-Control-Allow-Origin"] = result.AllowedOrigin;
    if (result.SupportsCredentials)
        response.Headers["Access-Control-Allow-Credentials"] = "true";
    // ... 写其他头
}

三者协作关系:

复制代码
CorsMiddleware
    │
    ├─ ICorsPolicyProvider.GetPolicyAsync()  ← 查:这个请求用哪条策略?
    │        └─ 返回 CorsPolicy 对象
    │
    └─ ICorsService.EvaluatePolicy()         ← 判:这个请求符合策略吗?
             └─ 返回 CorsResult
                    │
                    └─ ApplyResult()         ← 写:把结论写入响应头

四、请求执行阶段:CorsMiddleware 的处理逻辑

4.1 两种请求类型

CORS 协议将跨域请求分为两类,处理路径完全不同:

类型 判断条件 浏览器行为
Preflight(预检请求) OPTIONS 方法 + 含 Access-Control-Request-Method 先发预检,通过后才发实际请求
Actual Request(实际请求) 其他所有跨域请求 直接发送,依赖服务端响应头授权

触发 Preflight 的条件(满足任一即需要预检):

  • 使用了非简单方法(PUT、DELETE、PATCH 等)
  • 设置了非简单请求头(如 AuthorizationContent-Type: application/json
  • 使用了 ReadableStream

4.2 CorsMiddleware.InvokeAsync 完整决策树

csharp 复制代码
// 关键判断
var isOptionsRequest = HttpMethods.IsOptions(context.Request.Method);
var isCorsPreflightRequest = isOptionsRequest
    && context.Request.Headers.ContainsKey("Access-Control-Request-Method");
复制代码
收到请求
│
├─ 无 Origin 头?
│    └─ 非跨域请求 → 直接 next(),完全不处理
│
└─ 有 Origin 头
     ├─ 查找策略(ICorsPolicyProvider.GetPolicyAsync)
     │    ├─ UseCors("name") → 按名称查
     │    ├─ [EnableCors("name")] Attribute → 按 Endpoint Metadata 查
     │    └─ 找不到策略 → 记日志 → next()(不附加 CORS 头)
     │
     └─ 找到策略
          ├─ isCorsPreflightRequest == true
          │    ├─ CorsService.EvaluatePreflightPolicy()
          │    │    ├─ 校验 Origin
          │    │    ├─ 校验 Access-Control-Request-Method
          │    │    └─ 校验 Access-Control-Request-Headers
          │    │
          │    ├─ 全部通过 → 写响应头 → 返回 204(终止管道,不进业务逻辑)
          │    └─ 未通过   → 返回 200(不含 CORS 头,浏览器判定为拒绝)
          │
          └─ isCorsPreflightRequest == false(实际请求)
               ├─ CorsService.EvaluatePolicy()
               │    └─ 校验 Origin,写 Allow-Origin / Expose-Headers 等头
               └─ next()(继续执行后续中间件和业务逻辑)

4.3 为什么实际请求也需要 EvaluatePolicy 和写响应头?

这是常见误区。Preflight 通过不等于后续实际请求自动放行 ------浏览器在收到每一次实际请求的响应时,都会再次检查响应头中是否包含 Access-Control-Allow-Origin,只有包含且匹配,才会把响应数据交给 JS 代码。

复制代码
实际请求的响应头缺失时:

HTTP/1.1 200 OK
Content-Type: application/json
← 没有 Access-Control-Allow-Origin

{"secret": "data"}
← 数据已到达浏览器,但 JS 永远读不到(浏览器在这里拦截)

Preflight 与实际请求的响应头对比:

响应头 Preflight 实际请求
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Access-Control-Max-Age
Access-Control-Allow-Credentials
Access-Control-Expose-Headers
终止管道 ✅(204) ❌(继续业务)

Expose-Headers 只在实际请求阶段有意义------它告诉浏览器哪些自定义响应头(如 X-Request-Id)可以被 JS 的 response.headers.get() 读取。

4.4 Preflight 的完整时序

复制代码
浏览器                                服务端(ASP.NET Core)
  │                                         │
  │─── OPTIONS /api/data ─────────────────>│
  │    Origin: https://app.example.com      │
  │    Access-Control-Request-Method: PUT   │
  │    Access-Control-Request-Headers: Authorization
  │                                         │
  │                           CorsMiddleware 拦截
  │                           EvaluatePreflightPolicy 校验
  │                           ✅ 全部通过
  │                                         │
  │<── 204 No Content ────────────────────│
  │    Access-Control-Allow-Origin: https://app.example.com
  │    Access-Control-Allow-Methods: PUT   │
  │    Access-Control-Allow-Headers: Authorization
  │    Access-Control-Max-Age: 600         │ ← 600秒内不再重复预检
  │                                         │
  │─── PUT /api/data ──────────────────────>│ ← 真正的业务请求
  │    Origin: https://app.example.com      │
  │    Authorization: Bearer xxx            │
  │                                         │
  │<── 200 OK ─────────────────────────────│
  │    Access-Control-Allow-Origin: https://app.example.com
  │    Content-Type: application/json       │
  │    {业务数据}                            │
  │                                         │
  JS 读取响应 ✅

4.5 Preflight 缓存:服务端无感知

服务端对 Preflight 状态完全无感知------这是 CORS 协议的设计哲学。缓存完全在浏览器侧维护:

复制代码
浏览器内部维护 CORS Preflight Cache:
  Key:   (Origin, URL, Method, Headers)
  Value: 允许的方法/头,过期时间(= Max-Age)

发起请求时:
  查缓存 → 命中且未过期 → 直接发实际请求(跳过 OPTIONS)
  查缓存 → 未命中或已过期 → 先发 OPTIONS,再发实际请求

服务端每次都按相同逻辑处理:有 Origin 头 → 查策略 → 写响应头。它不知道也不需要知道浏览器之前是否发过 Preflight。


五、EvaluatePolicy vs EvaluatePreflightPolicy:源码级对比

csharp 复制代码
// ── Origin 校验(两种请求共用)────────────────────────────
private void EvaluateOrigin(CorsResult result, CorsPolicy policy, string origin)
{
    if (policy.AllowAnyOrigin)
    {
        result.AllowedOrigin = "*";
        result.IsOriginAllowed = true;
    }
    else if (policy.IsOriginAllowed(origin))  // 白名单 or 自定义委托
    {
        result.AllowedOrigin = origin;         // 具体值,非通配符
        result.IsOriginAllowed = true;
        result.VaryByOrigin = true;            // 触发 Vary: Origin 响应头
    }
    // IsOriginAllowed 默认 false,Origin 不匹配则不写任何 CORS 头
}

// ── Preflight 专属校验 ────────────────────────────────────
private void EvaluatePreflightRequest(CorsResult result, CorsPolicy policy, HttpContext context)
{
    if (!result.IsOriginAllowed) return;

    // 校验 Access-Control-Request-Method
    var requestMethod = context.Request.Headers["Access-Control-Request-Method"].ToString();
    if (policy.AllowAnyMethod
        || policy.Methods.Contains(requestMethod, StringComparer.OrdinalIgnoreCase))
    {
        result.AllowedMethods.Add(requestMethod);
    }

    // 逐个校验 Access-Control-Request-Headers
    var requestHeaders = context.Request.Headers["Access-Control-Request-Headers"]
                                 .ToString().Split(',');
    foreach (var header in requestHeaders)
    {
        if (policy.AllowAnyHeader
            || policy.Headers.Contains(header.Trim(), StringComparer.OrdinalIgnoreCase))
        {
            result.AllowedHeaders.Add(header.Trim());
        }
    }

    if (policy.PreflightMaxAge.HasValue)
        result.PreflightMaxAge = policy.PreflightMaxAge;
}

// ── 实际请求专属处理 ──────────────────────────────────────
private void EvaluateRequest(CorsResult result, CorsPolicy policy, HttpContext context)
{
    if (!result.IsOriginAllowed) return;

    // 实际请求不再校验 Method/Header(Preflight 阶段已完成)
    // 只处理 Expose-Headers:声明哪些响应头可被 JS 读取
    if (policy.ExposedHeaders.Count > 0)
        result.AllowedExposedHeaders.AddRange(policy.ExposedHeaders);

    if (policy.SupportsCredentials)
        result.SupportsCredentials = true;
}

六、Origin 头的安全性

6.1 浏览器自动设置,开发者无法干预

Origin 头被浏览器列为禁止修改的请求头(Forbidden Request Header)。当 JS 试图覆盖它时,浏览器会静默忽略:

javascript 复制代码
fetch(url, {
    headers: { "Origin": "https://fake.com" }  // 浏览器直接忽略这行
})
// 实际发送的 Origin 仍然是真实的页面来源

浏览器添加 Origin 的规则:

场景 是否加 Origin
跨域 fetch/XHR ✅ 自动加,值为当前页面来源
同源请求 ❌ 不加(或加同源值)
Preflight OPTIONS ✅ 必加
浏览器地址栏直接访问 ❌ 不加
curl / Postman ❌ 不自动加,可手动指定任意值

最后一条揭示了 CORS 的根本局限。

6.2 CORS 只保护浏览器环境

bash 复制代码
# curl 可以随意指定 Origin,服务端无法区分
curl -H "Origin: https://trusted.com" https://api.example.com/data

CORS 的保护对象是浏览器中的 JS 代码,对服务端到服务端的调用没有任何约束力。 API 的访问控制必须依靠认证(Authentication)和授权(Authorization)机制,而不能仅仅依赖 CORS。


七、CORS 的安全边界:它解决不了什么

7.1 CORS 不阻止请求到达服务端

这是最容易被误解的地方。CORS 只控制"浏览器中的 JS 能否读取响应",不阻止请求本身发出和到达服务端

复制代码
PUT /api/transfer(删除操作)

① 浏览器发出请求(跨域)
② 服务端收到请求,执行了操作
③ 服务端返回 200 + 响应体
④ 浏览器检查响应头,没有 Access-Control-Allow-Origin
   → 拦截响应,JS 读不到结果

但操作已经执行了!

对于有副作用的请求(POST/PUT/DELETE),Preflight 的存在能在一定程度上提前拦截,但 Form 表单提交、<img> 标签、<script> 等方式可以绕过 Preflight 直接触发请求------这就是 CSRF 攻击的入口。


八、CSRF 攻击与防御

8.1 攻击原理

CSRF 利用的是浏览器自动携带 Cookie 的特性:

复制代码
① 用户登录 bank.com,浏览器存有 Cookie: session=abc123

② 用户访问 evil.com,evil.com 页面包含:
   <form action="https://bank.com/transfer" method="POST">
     <input name="to"     value="attacker_account">
     <input name="amount" value="10000">
   </form>
   <script>document.forms[0].submit()</script>

③ 浏览器发出:
   POST https://bank.com/transfer
   Cookie: session=abc123  ← 自动携带,bank.com 的服务端认为合法

④ bank.com 服务端:验 Cookie → 合法 → 执行转账 ✅(被欺骗)

Form 表单提交是历史遗留的浏览器行为,CORS 对此无效。

8.2 CSRF Token 防御

核心思路:在请求中加入一个只有真正来源页面才能知道的秘密值。 攻击者能伪造 Cookie(浏览器自动携带),但无法读取 bank.com 页面内容(SOP 阻止),因此无法获取 Token。

Synchronizer Token Pattern(同步器令牌):

复制代码
服务端:
  1. 用户登录时生成随机 Token,与 Session 绑定
     session["csrf_token"] = RandomBytes(32).ToBase64()

  2. 渲染页面时注入 HTML:
     <input type="hidden" name="_csrf" value="{{csrf_token}}">

  3. 收到请求时校验:
     submitted_token == session["csrf_token"] ?

Double Submit Cookie(双重提交 Cookie,适用于无状态 API):

复制代码
① 服务端同时下发:
   Set-Cookie: csrf=x9f2...; SameSite=Strict(自动携带)
   Response Header: X-CSRF-Token: x9f2...(前端存储)

② 前端发请求时同时携带:
   Cookie: csrf=x9f2...         ← 浏览器自动带
   Header: X-CSRF-Token: x9f2... ← JS 手动加入

③ 服务端校验 Cookie 值 == Header 值
   攻击者无法读取 Cookie 的具体值(SOP 限制),因此无法伪造 Header

8.3 SameSite Cookie:釜底抽薪

现代浏览器支持 SameSite 属性,从根源上断绝 CSRF 的利用点:

复制代码
Set-Cookie: session=abc123; SameSite=Strict
SameSite 值 行为
Strict 任何跨站请求都不携带此 Cookie
Lax 跨站 GET 导航(点链接)携带,跨站 POST/fetch 不携带
None 旧行为,始终携带(需配合 Secure

evil.com 触发的请求,浏览器直接不带 session Cookie → bank.com 认为未登录 → 拒绝执行。

8.4 ASP.NET Core 的内置实现

csharp 复制代码
// 注册服务
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-CSRF-TOKEN";
    options.Cookie.SameSite = SameSiteMode.Strict;
});

// 下发 Token 给 SPA 前端
app.MapGet("/antiforgery/token", (IAntiforgery antiforgery, HttpContext ctx) =>
{
    var tokens = antiforgery.GetAndStoreTokens(ctx);
    return Results.Ok(new { token = tokens.RequestToken });
});

// 校验(Minimal API)
app.MapPost("/transfer", async (IAntiforgery antiforgery, HttpContext ctx) =>
{
    await antiforgery.ValidateRequestAsync(ctx);  // 不通过直接抛异常
    // 业务逻辑...
});

// 或全局启用(.NET 8+)
app.UseAntiforgery();

前端使用:

javascript 复制代码
// 获取 Token
const { token } = await fetch("/antiforgery/token").then(r => r.json());

// 请求时附带
await fetch("/api/transfer", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-CSRF-TOKEN": token
    },
    credentials: "include",
    body: JSON.stringify({ to: "friend", amount: 100 })
});

九、中间件顺序的强制要求

csharp 复制代码
app.UseRouting();       // ① 解析路由,确定目标 Endpoint
app.UseCors();          // ② 读取 Endpoint 上的 [EnableCors] Metadata,执行校验
app.UseAuthentication();
app.UseAuthorization(); // ③ 必须在 CORS 之后,否则 OPTIONS 预检被拦截返回 401
app.UseEndpoints();     // ④ 执行 Controller/Action

UseCors 必须在 UseRouting 之后调用,否则中间件读不到路由元数据([EnableCors] Attribute 会失效);必须在 UseAuthorization 之前调用,否则浏览器发出的 Preflight OPTIONS 请求会因未携带认证信息而返回 401,导致所有跨域请求失败。

另有一个重要的安全约束:AllowCredentials()AllowAnyOrigin() 不能同时使用,否则运行时抛出异常:

复制代码
InvalidOperationException: The CORS protocol does not allow specifying
a wildcard (any) origin and credentials at the same time.

这是 CORS 规范的硬性要求:凭据模式下,服务端必须指定具体的 Origin,不允许使用 *


十、总结:三种机制的边界

复制代码
SOP(Same-Origin Policy)
│  浏览器底层安全规则
│  "不同源的 JS 不能读取对方响应"
│
├─► CORS:SOP 的"合法豁免"
│         服务端声明"我允许某些跨域来源读取我的响应"
│         解决:前后端分离时,JS 能否读跨域 API 的响应
│         防护对象:浏览器 JS 的跨域读取
│         无效场景:服务端直接调用 API、curl 工具
│
└─► CSRF:SOP 挡不住的攻击面
          利用"浏览器自动携带 Cookie",不需要读响应
          解决:防止第三方网站以用户身份执行操作
          防御手段:CSRF Token、SameSite Cookie
          根本原则:证明"请求确实来自服务端下发的页面"

一句话记忆:

  • SOP 保护你:别人的网页不能读取你的数据
  • CORS 是你主动授权:我允许某些来源读取我的响应
  • CSRF 是绕过保护:冒用你的身份执行操作,需要 Token 来防御
相关推荐
学以智用1 小时前
.NET Core 完整特性速查表(终极版)
后端·.net
XovH1 小时前
第28篇 k8s之Service:为 Pod 提供稳定的访问入口
后端
用户2181697049301 小时前
Gin (三) 中间件 并发测试
后端
fliter1 小时前
你想在 Rust 中实现动态库热重载?
后端
用户467245132231 小时前
分布式唯一序列号:万亿级订单不重复的奥秘
后端
未秃头的程序猿1 小时前
别再让大模型单打独斗了!Java 多 Agent 协作实战:任务拆解+结果聚合
java·后端·ai编程
XovH1 小时前
第29篇 k8s之Service 与 Endpoints 深入:服务发现原理
后端
人道领域1 小时前
【LeetCode刷题日记】538.把二叉搜索树转换为累加树
java·开发语言·后端·算法·leetcode
西凉的悲伤2 小时前
Spring Boot + ShardingSphere 介绍
java·spring boot·后端·shardingsphere·分库分表