一、背景SOP 与 CORS 的关系理解 CORS 之前必须先理解它试图解决的问题从哪里来。1.1 SOPSame-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命名空间下的扩展方法本质上只做三件事publicstaticIServiceCollectionAddCors(thisIServiceCollectionservices,ActionCorsOptionssetupAction){services.AddOptions();services.TryAdd(ServiceDescriptor.TransientICorsService,CorsService());services.TryAdd(ServiceDescriptor.TransientICorsPolicyProvider,DefaultCorsPolicyProvider());services.Configure(setupAction);// 把 CorsOptions 写入配置系统returnservices;}注册内容生命周期作用ICorsService→CorsServiceTransient执行策略校验、写响应头的核心引擎ICorsPolicyProvider→DefaultCorsPolicyProviderTransient按名称查找策略的提供者CorsOptions通过setupActionOptions存储所有命名策略的内存字典2.2 策略是如何存储的setupAction中调用的options.AddPolicy(name, builder {...})实际执行逻辑// CorsOptions 内部privatereadonlyDictionarystring,CorsPolicy_policiesnew();publicvoidAddPolicy(stringname,ActionCorsPolicyBuilderconfigurePolicy){varbuildernewCorsPolicyBuilder();configurePolicy(builder);_policies[name]builder.Build();// Build() 冻结成不可变的 CorsPolicy 对象}CorsPolicyBuilder.Build()返回的CorsPolicy是一个不可变快照publicclassCorsPolicy{publicIListstringOrigins{get;}// 允许来源列表publicIListstringMethods{get;}// 允许方法列表publicIListstringHeaders{get;}// 允许头部列表publicIListstringExposedHeaders{get;}// 暴露给 JS 的响应头publicboolAllowCredentials{get;}publicTimeSpan?PreflightMaxAge{get;}// preflight 缓存时长publicFuncstring,boolIsOriginAllowed{get;}// 动态来源校验委托}2.3 多策略配置示例典型的多策略配置来源于appsettings.jsonservices.AddCors(options{foreach(varkvincorsPolicies){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();});}});对应配置文件结构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.1ICorsPolicyProvider策略查找器publicinterfaceICorsPolicyProvider{TaskCorsPolicy?GetPolicyAsync(HttpContextcontext,string?policyName);}默认实现DefaultCorsPolicyProvider的逻辑极其简单publicTaskCorsPolicy?GetPolicyAsync(HttpContextcontext,string?policyName){varoptions_options.Value;CorsPolicy?policy;if(policyName!null)options.PolicyMap.TryGetValue(policyName,outpolicy);// 按名查找elsepolicyoptions.DefaultPolicy;// 取默认策略returnTask.FromResult(policy);}它只负责从字典里找策略不做任何校验。这也意味着你可以实现自定义的ICorsPolicyProvider例如从数据库动态加载策略而无需修改任何其他代码。3.2ICorsService策略执行引擎publicinterfaceICorsService{CorsResultEvaluatePolicy(HttpContextcontext,CorsPolicypolicy);voidApplyResult(CorsResultresult,HttpResponseresponse);}CorsService负责两件事① 校验请求是否符合策略返回CorsResultpublicCorsResultEvaluatePolicy(HttpContextcontext,CorsPolicypolicy){varresultnewCorsResult();varorigincontext.Request.Headers[HeaderNames.Origin].ToString();if(string.IsNullOrEmpty(origin))returnresult;// 无 Origin非跨域EvaluateOrigin(result,policy,origin);// 所有请求都校验 Originif(IsPreflightRequest(context))EvaluatePreflightRequest(result,policy,context);elseEvaluateRequest(result,policy,context);returnresult;}② 把CorsResult写入 HTTP 响应头publicvoidApplyResult(CorsResultresult,HttpResponseresponse){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使用了ReadableStream4.2CorsMiddleware.InvokeAsync完整决策树// 关键判断varisOptionsRequestHttpMethods.IsOptions(context.Request.Method);varisCorsPreflightRequestisOptionsRequestcontext.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源码级对比// ── Origin 校验两种请求共用────────────────────────────privatevoidEvaluateOrigin(CorsResultresult,CorsPolicypolicy,stringorigin){if(policy.AllowAnyOrigin){result.AllowedOrigin*;result.IsOriginAllowedtrue;}elseif(policy.IsOriginAllowed(origin))// 白名单 or 自定义委托{result.AllowedOriginorigin;// 具体值非通配符result.IsOriginAllowedtrue;result.VaryByOrigintrue;// 触发 Vary: Origin 响应头}// IsOriginAllowed 默认 falseOrigin 不匹配则不写任何 CORS 头}// ── Preflight 专属校验 ────────────────────────────────────privatevoidEvaluatePreflightRequest(CorsResultresult,CorsPolicypolicy,HttpContextcontext){if(!result.IsOriginAllowed)return;// 校验 Access-Control-Request-MethodvarrequestMethodcontext.Request.Headers[Access-Control-Request-Method].ToString();if(policy.AllowAnyMethod||policy.Methods.Contains(requestMethod,StringComparer.OrdinalIgnoreCase)){result.AllowedMethods.Add(requestMethod);}// 逐个校验 Access-Control-Request-HeadersvarrequestHeaderscontext.Request.Headers[Access-Control-Request-Headers].ToString().Split(,);foreach(varheaderinrequestHeaders){if(policy.AllowAnyHeader||policy.Headers.Contains(header.Trim(),StringComparer.OrdinalIgnoreCase)){result.AllowedHeaders.Add(header.Trim());}}if(policy.PreflightMaxAge.HasValue)result.PreflightMaxAgepolicy.PreflightMaxAge;}// ── 实际请求专属处理 ──────────────────────────────────────privatevoidEvaluateRequest(CorsResultresult,CorsPolicypolicy,HttpContextcontext){if(!result.IsOriginAllowed)return;// 实际请求不再校验 Method/HeaderPreflight 阶段已完成// 只处理 Expose-Headers声明哪些响应头可被 JS 读取if(policy.ExposedHeaders.Count0)result.AllowedExposedHeaders.AddRange(policy.ExposedHeaders);if(policy.SupportsCredentials)result.SupportsCredentialstrue;}六、Origin 头的安全性6.1 浏览器自动设置开发者无法干预Origin头被浏览器列为禁止修改的请求头Forbidden Request Header。当 JS 试图覆盖它时浏览器会静默忽略fetch(url,{headers:{Origin:https://fake.com}// 浏览器直接忽略这行})// 实际发送的 Origin 仍然是真实的页面来源浏览器添加Origin的规则场景是否加 Origin跨域 fetch/XHR✅ 自动加值为当前页面来源同源请求❌ 不加或加同源值Preflight OPTIONS✅ 必加浏览器地址栏直接访问❌ 不加curl / Postman❌ 不自动加可手动指定任意值最后一条揭示了 CORS 的根本局限。6.2 CORS 只保护浏览器环境# curl 可以随意指定 Origin服务端无法区分curl-HOrigin: https://trusted.comhttps://api.example.com/dataCORS 的保护对象是浏览器中的 JS 代码对服务端到服务端的调用没有任何约束力。API 的访问控制必须依靠认证Authentication和授权Authorization机制而不能仅仅依赖 CORS。七、CORS 的安全边界它解决不了什么7.1 CORS 不阻止请求到达服务端这是最容易被误解的地方。CORS 只控制浏览器中的 JS 能否读取响应不阻止请求本身发出和到达服务端。PUT /api/transfer删除操作 ① 浏览器发出请求跨域 ② 服务端收到请求执行了操作 ③ 服务端返回 200 响应体 ④ 浏览器检查响应头没有 Access-Control-Allow-Origin → 拦截响应JS 读不到结果 但操作已经执行了对于有副作用的请求POST/PUT/DELETEPreflight 的存在能在一定程度上提前拦截但 Form 表单提交、img标签、script等方式可以绕过 Preflight 直接触发请求——这就是 CSRF 攻击的入口。八、CSRF 攻击与防御8.1 攻击原理CSRF 利用的是浏览器自动携带 Cookie的特性① 用户登录 bank.com浏览器存有 Cookie: sessionabc123 ② 用户访问 evil.comevil.com 页面包含 form actionhttps://bank.com/transfer methodPOST input nameto valueattacker_account input nameamount value10000 /form scriptdocument.forms[0].submit()/script ③ 浏览器发出 POST https://bank.com/transfer Cookie: sessionabc123 ← 自动携带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 typehidden name_csrf value{{csrf_token}} 3. 收到请求时校验 submitted_token session[csrf_token] ?Double Submit Cookie双重提交 Cookie适用于无状态 API① 服务端同时下发 Set-Cookie: csrfx9f2...; SameSiteStrict自动携带 Response Header: X-CSRF-Token: x9f2...前端存储 ② 前端发请求时同时携带 Cookie: csrfx9f2... ← 浏览器自动带 Header: X-CSRF-Token: x9f2... ← JS 手动加入 ③ 服务端校验 Cookie 值 Header 值 攻击者无法读取 Cookie 的具体值SOP 限制因此无法伪造 Header8.3 SameSite Cookie釜底抽薪现代浏览器支持SameSite属性从根源上断绝 CSRF 的利用点Set-Cookie: sessionabc123; SameSiteStrictSameSite 值行为Strict任何跨站请求都不携带此 CookieLax跨站 GET 导航点链接携带跨站 POST/fetch 不携带None旧行为始终携带需配合Secureevil.com 触发的请求浏览器直接不带 session Cookie → bank.com 认为未登录 → 拒绝执行。8.4 ASP.NET Core 的内置实现// 注册服务builder.Services.AddAntiforgery(options{options.HeaderNameX-CSRF-TOKEN;options.Cookie.SameSiteSameSiteMode.Strict;});// 下发 Token 给 SPA 前端app.MapGet(/antiforgery/token,(IAntiforgeryantiforgery,HttpContextctx){vartokensantiforgery.GetAndStoreTokens(ctx);returnResults.Ok(new{tokentokens.RequestToken});});// 校验Minimal APIapp.MapPost(/transfer,async(IAntiforgeryantiforgery,HttpContextctx){awaitantiforgery.ValidateRequestAsync(ctx);// 不通过直接抛异常// 业务逻辑...});// 或全局启用.NET 8app.UseAntiforgery();前端使用// 获取 Tokenconst{token}awaitfetch(/antiforgery/token).then(rr.json());// 请求时附带awaitfetch(/api/transfer,{method:POST,headers:{Content-Type:application/json,X-CSRF-TOKEN:token},credentials:include,body:JSON.stringify({to:friend,amount:100})});九、中间件顺序的强制要求app.UseRouting();// ① 解析路由确定目标 Endpointapp.UseCors();// ② 读取 Endpoint 上的 [EnableCors] Metadata执行校验app.UseAuthentication();app.UseAuthorization();// ③ 必须在 CORS 之后否则 OPTIONS 预检被拦截返回 401app.UseEndpoints();// ④ 执行 Controller/ActionUseCors必须在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不允许使用*。十、总结三种机制的边界SOPSame-Origin Policy │ 浏览器底层安全规则 │ 不同源的 JS 不能读取对方响应 │ ├─► CORSSOP 的合法豁免 │ 服务端声明我允许某些跨域来源读取我的响应 │ 解决前后端分离时JS 能否读跨域 API 的响应 │ 防护对象浏览器 JS 的跨域读取 │ 无效场景服务端直接调用 API、curl 工具 │ └─► CSRFSOP 挡不住的攻击面 利用浏览器自动携带 Cookie不需要读响应 解决防止第三方网站以用户身份执行操作 防御手段CSRF Token、SameSite Cookie 根本原则证明请求确实来自服务端下发的页面一句话记忆SOP保护你别人的网页不能读取你的数据CORS是你主动授权我允许某些来源读取我的响应CSRF是绕过保护冒用你的身份执行操作需要 Token 来防御