本文是 #源代码探案系列# 第三篇,今天这篇博客,我们来一起解读下 ASP.NET Core 中的 CORS 中间件,熟悉这个中间件的的小伙伴们,想必都已经猜出本文的主题:跨域。这确实是一个老生常谈的话题,可我并不认为,大家愿意去深入探究这个问题,因为博主曾经发现,每当工作中遇到跨域问题的时候,更多的是直接重写跨域相关的 HTTP 头。博主曾经写过一篇关于跨域的博客:《聊聊前端跨域的爱恨情仇》,当时是完全以前端的视角来看待跨域。所以,在今天这篇博客里,博主想带领大家从一种新的视角来看待跨域,也许,可以从中发现不一样的东西。

核心流程

关于 ASP.NET Core 中的 CORS,大家都知道的是,可以通过UseCors()方法在整个 HTTP 请求管道中启用跨域中间件,或者是通过AddCors()方法来定义跨域策略,亦或者通过[EnableCors]来显式地指定跨域策略,更多的细节大家可以参考微软的官方文档,而在这里,我想聊一点大家可能不知道的东西,譬如:服务器端如何处理来自浏览器端的跨域请求?而这一切在 ASP.NET Core 中又如何实现?带着这些问题来解读 CORS 中间件的源代码,我们能更快的找到我们想得到的答案。一图胜千言,请允许博主使用这张流程图来“开宗明义”,我们这就开始今天的“探案”:

一张图览尽 CORS 中间件
一张图览尽 CORS 中间件

核心部件

对于整个 CORS 中间件而言,核心部件主要有:CorsPolicyCorsService 以及 CorsMiddleware

CorsPolicy

整个 CORS 中间件中,首当其冲的是ICorsPolicy。这个接口的作用是定义跨域的策略,我们知道CORS中引入了Access-Control系列的 HTTP 头,所以,CorsPolicy 本质上是在定义允许哪些 HTTP 头、HTTP 方法、源(Origin) 可以访问受限的资源,以及当跨域请求是一个复杂请求的时候,预检请求的超时时间、是否支持凭据等等:

1
2
3
4
5
6
7
8
9
10
11
12
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接口产生直接联系的,是CorsPolicyBuilderICorsPolicyProvider。相信大家从命名上就可以了解到,前者是一个基于建造者模式的、针对 CorsPolicy进行“加工”的工具类,可以快速地对 跨域策略中允许的 HTTP 方法、HTTP 头、源(Origin)等信息进行修改。关于这一点,我们可以从CorsPolicyBuilder提供的方法签名中得到印证,而最终CorsPolicyBuilder通过Build()方法来返回一个“加工”好的CorsPolicy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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中的配置系统依赖注入,对于这种“套路”应该不会感到陌生。这里,微软提供了一个默认实现:DefaultCorsPolicyProviderDefaultCorsPolicyProvider本身依赖CorsOptions,允许使用者传入一个CorsPolicy的实例 或者是一个委托,来自定义跨域策略的“加工”细节,并在其内部维护一个字典,来实现具名的跨域策略。如果使用者不为当前跨域策略指定名称,则会使用默认的跨域策略名称。在大多数场景下,我们并不会直接使用CorsPolicyBuilder,而是在Startup类中通过委托来定义跨域策略,两者可以说是不同层次上的跨域策略的“提供者”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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()方法去处理。除了HttpContextCorsPolicy这两个参数以外,它们都会接受第三个参数CorsResult,它里面封装了我们一开始判断出来的关于源和预检请求的信息。继续细看,我们会发现这两个方法,都调用了PopulateResult()方法,继续顺着这条线索下去,我们就会发现,这个方法的主要作用是,结合跨域策略设定的各种参数,进一步对上一步生成的CorsResult进行“加工”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers
Access-Control-Max-Age…等等。事实上,在CorsMiddleware中间件中,原本就是先调用EvaluateResult()方法,再调用ApplyResult()方法。当然,实际的代码中,还需要考虑[DisableCors][EnableCors]两个特性的影响,会多出一点判断的代码。关于跨域的代码层面的东西,我们就先讲到这里,在下一部分,我们会专门讲CORS里的简单请求和复杂请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 头部来指示服务器端对除自身以外的源(域、协议、端口)是否可以访问指定的资源。你可能会联想到OAuth2JWT等等关于认证授权的词汇,请注意,“跨域”始终发生在浏览器端,相对于浏览器,一般意义上的客户端都被视为可信任的。除此之外,CORS提供了一种被称之为“预检”的机制,它可以用来检测服务器端支持的 HTTP 请求头、HTTP 动词,在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。

为什么会发生跨域?
为什么会发生跨域?

如上图所示,浏览器端,特别是XMLHttpRequestFetch APIWeb字体 和 Canvas等始终遵循同源策略,domain-a.comdomain-b.com被视为两个不同域,因此,当domain-a.com试图访问domain-b.com下的资源时,就会被浏览器所限制,这就是我们所说的“跨域”。可能,这并不是一个特别好的例子,因为 HTML 中某些元素天生就被设计为允许跨域,例如:imageiframelinkscript等等。而如果我们通过“协商”来告诉domain-bdomain-a希望访问它下面的资源,这其实就是我们所说的 CORS 啦!这个“协商”过程呢,主要有两种,即 简单请求复杂请求

简单请求

我们将不触发 CORS 预检 的请求称为简单请求,通常情况下,简单请求满足下列条件:

对于 简单请求 ,由于它的 HTTP 动词是确定的,故其跨域主要体现在服务器端返回的 HTTP 响应中,可能出现的响应头有:Access-Control-Allow-OriginAccess-Control-Allow-Headers等。所以,如果客户端请求的Origin被包含在服务器端返回的Access-Control-Allow-Origin中,则表示跨域被允许,反之则不被允许。所以,现在大家应该能想明白,为啥那些年里大家稀里糊涂地,把Access-Control-Allow-OriginAccess-Control-Allow-Headers设置为*就万事大吉了吧,而对照着中间件的代码,理解这层含义会更容易一点!

复杂请求

与简单请求不同,复杂请求 要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

预检请求
预检请求

当浏览器检测到,从JavaScript中发起的请求需要被预检。此时,可以注意到,预检请求中同时携带了下面两个首部字段:

1
2
Access-Control-Request-Method: POST
Access-Control-Request-Headers:X-PINGOTHER, Content-Type

服务器在接受预检请求后,会返回以下响应头:

1
2
3
4
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 系列头部字段:

1
2
3
4
5
6
7
8
9
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 这种事实上的标准,其原理上利用 OriginAccess-Control系列的头来标识服务器端可以允许哪些源、以什么样的 HTTP 动词 / 头来访问资源,按照 CORS 规范,浏览器端发起的请求被分为: 简单请求复杂请求 两种,两者最大的区别是,复杂请求 必须首先通过 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。好了,以上就是这篇博客的全部内容啦,欢迎大家在博客评论中参与讨论,再次谢谢大家,晚安!