本文是 #源代码探案系列# 第三篇,今天这篇博客,我们来一起解读下 ASP.NET Core 中的 CORS 中间件,熟悉这个中间件的的小伙伴们,想必都已经猜出本文的主题:跨域。这确实是一个老生常谈的话题,可我并不认为,大家愿意去深入探究这个问题,因为博主曾经发现,每当工作中遇到跨域问题的时候,更多的是直接重写跨域相关的 HTTP 头。博主曾经写过一篇关于跨域的博客:《聊聊前端跨域的爱恨情仇》,当时是完全以前端的视角来看待跨域。所以,在今天这篇博客里,博主想带领大家从一种新的视角来看待跨域,也许,可以从中发现不一样的东西。
核心流程
关于 ASP.NET Core 中的 CORS,大家都知道的是,可以通过UseCors()
方法在整个 HTTP 请求管道中启用跨域中间件,或者是通过AddCors()
方法来定义跨域策略,亦或者通过[EnableCors]
来显式地指定跨域策略,更多的细节大家可以参考微软的官方文档,而在这里,我想聊一点大家可能不知道的东西,譬如:服务器端如何处理来自浏览器端的跨域请求?而这一切在 ASP.NET Core 中又如何实现?带着这些问题来解读 CORS 中间件的源代码,我们能更快的找到我们想得到的答案。一图胜千言,请允许博主使用这张流程图来“开宗明义”,我们这就开始今天的“探案”:
核心部件
对于整个 CORS 中间件而言,核心部件主要有:CorsPolicy、CorsService 以及 CorsMiddleware。
CorsPolicy
整个 CORS 中间件中,首当其冲的是ICorsPolicy
。这个接口的作用是定义跨域的策略,我们知道CORS
中引入了Access-Control
系列的 HTTP 头,所以,CorsPolicy
本质上是在定义允许哪些 HTTP 头、HTTP 方法、源(Origin) 可以访问受限的资源,以及当跨域请求是一个复杂请求的时候,预检请求的超时时间、是否支持凭据等等:
public class CorsPolicy
{
public bool AllowAnyHeader { get; }
public bool AllowAnyMethod { get; }
public bool AllowAnyOrigin { get; }
public Func<string, bool> IsOriginAllowed { get; private set; }
public IList<string> ExposedHeaders { get; } = new List<string>();
public IList<string> Headers { get; } = new List<string>();
public IList<string> Methods { get; } = new List<string>();
public IList<string> Origins { get; } = new List<string>();
public TimeSpan? PreflightMaxAge { get; set; }
public bool SupportsCredentials { get; set; }
在整个中间件的设计中,与CorsPolicy
接口产生直接联系的,是CorsPolicyBuilder
和ICorsPolicyProvider
。相信大家从命名上就可以了解到,前者是一个基于建造者模式的、针对 CorsPolicy
进行“加工”的工具类,可以快速地对 跨域策略中允许的 HTTP 方法、HTTP 头、源(Origin)等信息进行修改。关于这一点,我们可以从CorsPolicyBuilder
提供的方法签名中得到印证,而最终CorsPolicyBuilder
通过Build()
方法来返回一个“加工”好的CorsPolicy
。
public class CorsPolicyBuilder
{
CorsPolicyBuilder WithOrigins(params string[] origins);
CorsPolicyBuilder WithHeaders(params string[] headers);
CorsPolicyBuilder WithExposedHeaders(params string[] exposedHeaders);
CorsPolicyBuilder WithMethods(params string[] methods);
CorsPolicyBuilder AllowCredentials();
CorsPolicyBuilder DisallowCredentials();
CorsPolicyBuilder AllowAnyOrigin();
CorsPolicyBuilder AllowAnyMethod();
CorsPolicyBuilder AllowAnyHeader();
CorsPolicyBuilder SetPreflightMaxAge(TimeSpan preflightMaxAge);
CorsPolicyBuilder SetIsOriginAllowed(Func<string, bool> isOriginAllowed);
CorsPolicyBuilder SetIsOriginAllowedToAllowWildcardSubdomains();
CorsPolicy Build();
}
除了通过CorsPolicyBuilder
来生成跨域策略,我们还可以通过ICorsPolicyProvider
来生成跨域策略。如果你经常使用ASP.NET Core
中的配置系统和依赖注入,对于这种“套路”应该不会感到陌生。这里,微软提供了一个默认实现:DefaultCorsPolicyProvider
。DefaultCorsPolicyProvider
本身依赖CorsOptions
,允许使用者传入一个CorsPolicy
的实例 或者是一个委托,来自定义跨域策略的“加工”细节,并在其内部维护一个字典,来实现具名的跨域策略。如果使用者不为当前跨域策略指定名称,则会使用默认的跨域策略名称。在大多数场景下,我们并不会直接使用CorsPolicyBuilder
,而是在Startup
类中通过委托来定义跨域策略,两者可以说是不同层次上的跨域策略的“提供者”。
// DefaultCorsPolicyProvider的GetPolicyAsync()
public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
if (context == null){
throw new ArgumentNullException(nameof(context));
}
policyName ??= _options.DefaultPolicyName;
if (_options.PolicyMap.TryGetValue(policyName, out var result)) {
return result.policyTask!;
}
return NullResult;
}
// CorsOptions
public void AddDefaultPolicy(CorsPolicy policy);
public void AddDefaultPolicy(Action<CorsPolicyBuilder> configurePolicy);
public void AddPolicy(string name, CorsPolicy policy);
public void AddPolicy(string name, Action<CorsPolicyBuilder> configurePolicy);
public CorsPolicy? GetPolicy(string name);
CorsService
OK,说完了跨域策略的“定义”,现在我们来看看跨域策略是如何被中间件“执行”的,这部分代码被定义在CoreService
类的EvaluatePolicy()
方法中。可以注意到,如果受限资源允许任意源(Origin)访问,则服务器端会认为这是一个不安全的跨域策略。
接下来,从HttpContext
中提取客户端的源(Origin),请求方法(HttpMethod)。此时,服务器端可以根据请求方法和 HTTP 头 判断当前请求是都为预检请求。按照CORS规范,当请求方法为OPTION
且请求头中含有Access-Control-Request-Method
时,即表示这是一个预检请求。
至此,我们有了两种选择,预检请求会交给EvaluatePreflightRequest()
方法去处理,非预检请求会交给EvaluateRequest()
方法去处理。除了HttpContext
和CorsPolicy
这两个参数以外,它们都会接受第三个参数CorsResult
,它里面封装了我们一开始判断出来的关于源和预检请求的信息。继续细看,我们会发现这两个方法,都调用了PopulateResult()
方法,继续顺着这条线索下去,我们就会发现,这个方法的主要作用是,结合跨域策略设定的各种参数,进一步对上一步生成的CorsResult
进行“加工”。
public CorsResult EvaluatePolicy(HttpContext context, CorsPolicy policy)
{
// ...
if (policy.AllowAnyOrigin && policy.SupportsCredentials) {
throw new ArgumentException(Resources.InsecureConfiguration, nameof(policy));
}
var requestHeaders = context.Request.Headers;
var origin = requestHeaders[CorsConstants.Origin];
var isOptionsRequest = HttpMethods.IsOptions(context.Request.Method);
var isPreflightRequest = isOptionsRequest
&& requestHeaders.ContainsKey(CorsConstants.AccessControlRequestMethod);
var corsResult = new CorsResult {
IsPreflightRequest = isPreflightRequest,
IsOriginAllowed = IsOriginAllowed(policy, origin),
};
if (isPreflightRequest) {
//预检请求
EvaluatePreflightRequest(context, policy, corsResult);
}
else {
//非预检请求
EvaluateRequest(context, policy, corsResult);
}
return corsResult;
}
private static void PopulateResult(HttpContext context,
CorsPolicy policy,
CorsResult result
)
{
var headers = context.Request.Headers;
if (policy.AllowAnyOrigin) {
result.AllowedOrigin = CorsConstants.AnyOrigin;
result.VaryByOrigin = policy.SupportsCredentials;
} else {
var origin = headers[CorsConstants.Origin];
result.AllowedOrigin = origin;
result.VaryByOrigin = policy.Origins.Count > 1
|| !policy.IsDefaultIsOriginAllowed;
}
// 支持凭据
result.SupportsCredentials = policy.SupportsCredentials;
// 预检请求超时时间
result.PreflightMaxAge = policy.PreflightMaxAge;
// https://fetch.spec.whatwg.org/#http-new-header-syntax
AddHeaderValues(result.AllowedExposedHeaders, policy.ExposedHeaders);
// 允许的HTTP方法
var allowedMethods = policy.AllowAnyMethod ?
new[] { result.IsPreflightRequest ?
(string)headers[CorsConstants.AccessControlRequestMethod] :
context.Request.Method } :
policy.Methods;
AddHeaderValues(result.AllowedMethods, allowedMethods);
// 允许的HTTP头
var allowedHeaders = policy.AllowAnyHeader ?
headers.GetCommaSeparatedValues(CorsConstants.AccessControlRequestHeaders) :
policy.Headers;
AddHeaderValues(result.AllowedHeaders, allowedHeaders);
}
那么,这些参数最终的走向是哪里呢?我们注意到CorsService
里有一个叫做ApplyResult()
的方法,观察方法签名可以发现,它负责把跨域检测的结果应用到 HTTP 响应上,相信大家都能想到,这里会设置各种Access-Control
系列的头,比如Access-Control-Allow-Origin
、Access-Control-Allow-Methods
、Access-Control-Allow-Headers
、
Access-Control-Max-Age
…等等。事实上,在CorsMiddleware
中间件中,原本就是先调用EvaluateResult()
方法,再调用ApplyResult()
方法。当然,实际的代码中,还需要考虑[DisableCors]
和[EnableCors]
两个特性的影响,会多出一点判断的代码。关于跨域的代码层面的东西,我们就先讲到这里,在下一部分,我们会专门讲CORS
里的简单请求和复杂请求。
public Task Invoke(HttpContext context, ICorsPolicyProvider corsPolicyProvider)
{
// ...
if (!context.Request.Headers.ContainsKey(CorsConstants.Origin)) {
return _next(context);
}
// [DisableCors]
var corsMetadata = endpoint?.Metadata.GetMetadata<ICorsMetadata>();
if (corsMetadata is IDisableCorsAttribute) {
var isOptionsRequest = HttpMethods.IsOptions(context.Request.Method);
var isCorsPreflightRequest = isOptionsRequest
&& context.Request.Headers.ContainsKey(CorsConstants.AccessControlRequestMethod);
if (isCorsPreflightRequest) {
// If this is a preflight request, and we disallow CORS, complete the request
context.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
}
return _next(context);
}
// ...
// [EnableCors]
else if (corsMetadata is IEnableCorsAttribute enableCorsAttribute &&
enableCorsAttribute.PolicyName != null) {
// ...
// Evaluate && Apply
return EvaluateAndApplyPolicy(context, corsPolicy);
async Task InvokeCoreAwaited(HttpContext context, Task<CorsPolicy?> policyTask) {
var corsPolicy = await policyTask;
await EvaluateAndApplyPolicy(context, corsPolicy);
}
}
}
再论CORS
好了,行文至此。既然这篇博客的主题是“跨域”,那么,我们不妨多说一点。我们知道,“跨域”产生的背景是,浏览器作为一个公共环境,它本身是不被信任的,所以,为了杜绝非当前域的资源,例如Cookie、API等等被“窃取”,浏览器便增加了“跨域”这一限制。而为了顺应“前后端分离”、“微服务”等等的开发思想,“跨域”这个问题开始频繁地出现在人们的视野中,从最初的JSONP,到如今成为事实标准的CORS,甚至从Vue里的代理服务器、Nginx里的反向代理,我们总是能窥出一点“跨域”的影子,“跨域”可谓是无处不在。
那么,什么是 CORS 呢? CORS ,即跨域资源共享,是一种利用 HTTP 头部来指示服务器端对除自身以外的源(域、协议、端口)是否可以访问指定的资源。你可能会联想到OAuth2、JWT等等关于认证授权的词汇,请注意,“跨域”始终发生在浏览器端,相对于浏览器,一般意义上的客户端都被视为可信任的。除此之外,CORS提供了一种被称之为“预检”的机制,它可以用来检测服务器端支持的 HTTP 请求头、HTTP 动词,在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。
如上图所示,浏览器端,特别是XMLHttpRequest
、Fetch API
、Web
字体 和 Canvas
等始终遵循同源策略,domain-a.com
和domain-b.com
被视为两个不同域,因此,当domain-a.com
试图访问domain-b.com
下的资源时,就会被浏览器所限制,这就是我们所说的“跨域”。可能,这并不是一个特别好的例子,因为 HTML 中某些元素天生就被设计为允许跨域,例如:image
、iframe
、link
、script
等等。而如果我们通过“协商”来告诉domain-b
,domain-a
希望访问它下面的资源,这其实就是我们所说的 CORS 啦!这个“协商”过程呢,主要有两种,即 简单请求 和 复杂请求。
简单请求
我们将不触发 CORS 预检 的请求称为简单请求,通常情况下,简单请求满足下列条件:
- 使用下列方法之一:GET、HEAD 和 POST
- 除了被用户代理自动设置的首部字段(例如:Connection、User-Agent) 和 在 Fetch 规范中定义为 禁用首部名称 的其他首部,允许人为设置的字段为 Fetch 规范定义的 对 CORS 安全的首部字段集合。该集合为:Accept、Accept-Language、Content-Language、Content-Type、DPR、Downlink、Save-Data、Viewport-Width、Width
- Content-Type 的值仅限于下列三者之一:text/plain、multipart/form-data、application/x-www-form-urlencoded
- 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
- 请求中没有使用 ReadableStream 对象。
对于 简单请求 ,由于它的 HTTP 动词是确定的,故其跨域主要体现在服务器端返回的 HTTP 响应中,可能出现的响应头有:Access-Control-Allow-Origin
、Access-Control-Allow-Headers
等。所以,如果客户端请求的Origin
被包含在服务器端返回的Access-Control-Allow-Origin
中,则表示跨域被允许,反之则不被允许。所以,现在大家应该能想明白,为啥那些年里大家稀里糊涂地,把Access-Control-Allow-Origin
和Access-Control-Allow-Headers
设置为*
就万事大吉了吧,而对照着中间件的代码,理解这层含义会更容易一点!
复杂请求
与简单请求不同,复杂请求 要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。
当浏览器检测到,从JavaScript
中发起的请求需要被预检。此时,可以注意到,预检请求中同时携带了下面两个首部字段:
Access-Control-Request-Method: POST
Access-Control-Request-Headers:X-PINGOTHER, Content-Type
服务器在接受预检请求后,会返回以下响应头:
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
其中:
- 首部字段
Access-Control-Allow-Methods
表明服务器允许客户端使用 POST、GET 和 OPTIONS 方法发起请求。 - 首部字段
Access-Control-Allow-Headers
表明服务器允许请求中携带字段 X-PINGOTHER 与 Content-Type。 - 首部字段
Access-Control-Max-Age
表明该响应的有效时间为 86400 秒,即 24 小时。在有效时间内,浏览器无须为同一请求再次发起预检请求。
下面整理了 CORS 中常见的 Access-Control 系列头部字段:
Access-Control-Allow-Origin
Access-Control-Expose-Headers
Access-Control-Max-Age
Access-Control-Allow-Credentials
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Origin
Access-Control-Request-Method
Access-Control-Request-Headers
本文小结
本文分别从 源代码 和 规范 两个角度探讨了 “跨域” 这个话题,两者可以说是相辅相成的存在,CORS 中间件实现了 CORS 规范,而通过 CORS 规范帮助我们理解了中间件。“跨域”产生的背景是,浏览器作为一个公共环境,它本身是不被信任的,所以,为了杜绝非当前域的资源,例如Cookie、API等等被“窃取”,浏览器便增加了 “跨域” 这一限制。最初我们通过 JSONP 这种方案来解决跨域问题,而后来我们有了CORS 这种事实上的标准,其原理上利用 Origin 及 Access-Control系列的头来标识服务器端可以允许哪些源、以什么样的 HTTP 动词 / 头来访问资源,按照 CORS 规范,浏览器端发起的请求被分为: 简单请求 和 复杂请求 两种,两者最大的区别是,复杂请求 必须首先通过 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。好了,以上就是这篇博客的全部内容啦,欢迎大家在博客评论中参与讨论,再次谢谢大家,晚安!