返回

源代码探案系列之 .NET Core 跨域中间件 CORS

AI 摘要
本文探讨了ASP.NET Core中的CORS中间件,介绍了其核心流程和部件,包括CorsPolicy、CorsService和CorsMiddleware。讨论了跨域请求的处理流程,如预检请求和非预检请求的处理方式,以及如何应用CORS规范中的Access-Control系列头部字段。文章从源代码和规范两个角度深入探讨跨域问题的背景、发展以及CORS的原理和应用,涵盖了简单请求和复杂请求的区别。文章通过对CORS中间件和CORS规范的讨论,帮助读者全面理解跨域问题。

本文是 #源代码探案系列# 第三篇,今天这篇博客,我们来一起解读下 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) 可以访问受限的资源,以及当跨域请求是一个复杂请求的时候,预检请求的超时时间、是否支持凭据等等:

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

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类中通过委托来定义跨域策略,两者可以说是不同层次上的跨域策略的“提供者”。

// 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进行“加工”。

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-HeadersAccess-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 头部来指示服务器端对除自身以外的源(域、协议、端口)是否可以访问指定的资源。你可能会联想到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中发起的请求需要被预检。此时,可以注意到,预检请求中同时携带了下面两个首部字段:

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

Built with Hugo v0.126.1
Theme Stack designed by Jimmy
已创作 274 篇文章,共计 1038468 字