一、背景: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;
}
| 注册内容 | 生命周期 | 作用 |
|---|---|---|
ICorsService → CorsService |
Transient | 执行策略校验、写响应头的核心引擎 |
ICorsPolicyProvider → DefaultCorsPolicyProvider |
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 策略外置到配置文件,避免硬编码,支持多环境灵活切换。
三、核心组件分工:ICorsService 与 ICorsPolicyProvider
两者职责完全分离,体现了"查找"与"执行"的解耦设计。
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 等)
- 设置了非简单请求头(如
Authorization、Content-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 来防御