跨域问题自始至终都是前端比较头疼也是很基础的一个问题,在当下前后端分离的架构模式逐渐走向主流的情况下,更容易遇到。本文会通过请求和响应来讲讲分别需要处理哪些东西来允许 API 跨域。

前言

最近看了下 CloudFlare Workers,看到官方示例之后想到了可以用来解决一些 API 不允许跨域的问题,稍微尝试了下。所以这篇文章一开始本来是打算讲怎么利用 CloudFlare Workers 反向代理解决 API 跨域问题的,但是讲了不少关于跨域的事情,所以顺便总结了下前端跨域问题的解决办法及注意事项。

另外,由于我并不是专门做 Web 前端开发的,所以文中可能有不少错误的地方,希望大佬们能指出~

Cloudflare Workers provides a serverless execution environment that allows you to create entirely new applications or augment existing ones without configuring or maintaining infrastructure.

CloudFlare Workers 提供了一个无服务器执行环境,允许您创建全新的应用程序或扩充现有应用程序,而无需配置或维护基础设施。

其实 Serverless 平台有很多,这里选择了 CloudFlare 是因为我觉得它不仅免费而且稳定,而且网络也特别好(CF YYDS,到某些特别的地方除外)。

不过除了 CloudFlare 还有 Vercel 这个平台也相当不错,官网:https://vercel.com/

另外关于跨域的话还是建议看看 MDN 上面的文档,更加详细:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS

跨源资源共享 (CORS)(或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它origin(域,协议和端口),这样浏览器可以访问加载这些资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的”预检”请求。在预检中,浏览器发送的头中标示有HTTP方法和真实请求中会用到的头。

——摘自《MDN Web Docs

请求

Referer

Referer 请求头包含了当前请求页面的来源页面的地址,即表示当前页面是通过此来源页面里的链接进入的。服务端一般使用 Referer 请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。

需要注意的是 referer 实际上是 “referrer” 误拼写。参见 HTTP referer on Wikipedia (HTTP referer 在维基百科上的条目)来获取更详细的信息。

一般来说在一些防止你跨域请求的 API 都会限制一下 Referer,一般都是它那边调用到这个 API 的网址,通过 F12 查看网络流量就可以看见,APP 的话则需要抓包才能看见,还有一些 API 只允许空 Referer

Origin

请求首部字段 Origin 指示了请求来自于哪个站点。该字段仅指示服务器名称,并不包含任何路径信息。该首部用于 CORS 请求或者 POST 请求。除了不包含路径信息,该字段与 Referer 首部字段相似。

跨域请求的根本就是说的跨资源(Origin)共享,这也是一切的开端,而此项的处理办法与 Referer 相同。

Referrer-Policy

Referrer-Policy 首部用来监管哪些访问来源信息——会在 Referer 中发送——应该被包含在生成的请求当中。

注意 Referer 实际上是单词 “referrer” 的错误拼写。Referrer-Policy 这个首部并没有延续这个错误拼写。

所以在需要空 Referer 的时候,在请求头里就需要把 Referrer-Policy 设置为 no-referrer。其它情况一般来说很少遇到要改这个的吧(大概)。

响应

影响跨域响应的主要是有下面几个 HTTP 响应头。

Access-Control-Allow-Origin

此响应头指定了该响应的资源是否被允许与给定的origin共享。

在不考虑安全性和盗链的情况下,我们一般把它的值设置成 *,而当我们需要使用下一项所说的凭据(Credentials)时,则必须要指定此项的值为请求来源。

Access-Control-Allow-Credentials

此响应头表示是否可以将对请求的响应暴露给页面。返回true则可以,其他值均不可以。

Credentials可以是 cookies, authorization headers 或 TLS client certificates。

当作为对预检请求的响应的一部分时,这能表示是否真正的请求可以使用credentials。注意简单的GET 请求没有预检,所以若一个对资源的请求带了credentials,如果这个响应头没有随资源返回,响应就会被浏览器忽视,不会返回到web内容。

所以当要用到凭据的时候我们会把它设置成 true,而不需要用到凭据的 API 则不能传送此响应头

Access-Control-Allow-Headers

响应首部 Access-Control-Allow-Headers 用于 preflight request (预检请求)中,列出了将会在正式请求的 Access-Control-Request-Headers 字段中出现的首部信息。

如果请求中含有 Access-Control-Request-Headers 字段,那么这个首部是必要的。

可以看出我们只需要对预检请求(一般是 OPTIONS 请求方式)返回此响应头,所以它的值我们会先从请求头 Access-Control-Request-Headers 里面获取,如果获取不到才设置为 *

Access-Control-Allow-Methods

响应首部 Access-Control-Allow-Methods 在对 preflight request.(预检请求)的应答中明确了客户端所要访问的资源允许使用的方法或方法列表。

这个和上面的 Access-Control-Allow-Headers 类似,也是从对应的请求头 Access-Control-Request-Method 里面获取(别问为什么不是 Methods,强迫症难受),如果获取不到则设置为 *

Access-Control-Expose-Headers

响应首部 Access-Control-Expose-Headers 列出了哪些首部可以作为响应的一部分暴露给外部。

默认情况下,只有七种 simple response headers (简单响应首部)可以暴露给外部:

如果想要让客户端可以访问到其他的首部信息,可以将它们在 Access-Control-Expose-Headers 里面列出来。

所以如果有必要的话,可以将此项的值设置为 *,或者是你需要用到的响应头。

Content-Type

Content-Type 实体头部用于指示资源的MIME类型 media type

在响应中,Content-Type标头告诉客户端实际返回的内容的内容类型。

在反向代理 API 的时候也要特别注意这个响应头,有的 API 返回的内容明明是 JSON,响应头却是 text/plain,有时会就出现一些意料之外的情况,而且对后面提到的预检请求也有影响,所以这个响应头也要注意一下,保证和返回的内容匹配。常用的比如 JSON 为 application/json

注意事项

在响应附带身份凭证的请求时:

  • 服务器不能将 Access-Control-Allow-Origin 的值设为通配符“*”,而应将其设置为特定的域,如:Access-Control-Allow-Origin: https://example.com
  • 服务器不能将 Access-Control-Allow-Headers 的值设为通配符“*”,而应将其设置为首部名称的列表,如:Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
  • 服务器不能将 Access-Control-Allow-Methods 的值设为通配符“*”,而应将其设置为特定请求方法名称的列表,如:Access-Control-Allow-Methods: POST, GET

预检请求

一个 CORS 预检请求是用于检查服务器是否支持 CORS 即跨域资源共享。

它一般是用了以下几个 HTTP 请求首部的 OPTIONS 请求:Access-Control-Request-MethodAccess-Control-Request-Headers,以及一个 Origin 首部。

当有必要的时候,浏览器会自动发出一个预检请求;所以在正常情况下,前端开发者不需要自己去发这样的请求。

这个算是要特别注意的地方,如果服务端就没有正常响应预检请求,就会出现调试 API 一切正常,放在浏览器里面调用的时候还是报跨域请求错误的情况,因为浏览器在发送正式请求前会有一次预检请求。所以有时候在处理响应来使 API 能够被跨域请求的时候要单独对预检请求进行处理。

从上一部分响应头中我们已经可以看出,服务端要对 OPTIONS 请求方式正确的返回 Access-Control-Allow-HeadersAccess-Control-Allow-Methods 这两个响应头。除此之外,还需要返回正确的 Content-Type,保证其值和正式请求里返回的值一样。

示例

这里拿 UptimeRobot 的 API 举例,使用 CloudFlare Workers 来进行反向代理。

不需要用到凭据(Credentials)的情况下:

async function handleRequest(request) {
    const { pathname, searchParams } = new URL(request.url);
    const param = searchParams.toString() ? "?" + searchParams.toString() : "";
    const requestHeaders = request.headers.get("Access-Control-Request-Headers");
    const requestMethod = request.headers.get("Access-Control-Request-Method");
    
    // 响应预检请求
    if (request.method == "OPTIONS") {
        return new Response('{"Access": "OPTIONS"}', {
            status: 200,
            headers: {
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Headers": requestHeaders ? requestHeaders : "*",
                "Access-Control-Allow-Methods": requestMethod ? requestMethod : "*",
                "Access-Control-Expose-Headers": "*",
                "Content-Type": "application/json"
            }
        });
    }
    
    // 处理正式请求
    const newRequest = new Request("https://api.uptimerobot.com" + pathname + param, request);
    // 请求头删除来源
    newRequest.headers.set("referrer-policy", "no-referrer");
    newRequest.headers.delete("Referer");
    newRequest.headers.delete("Origin");
    // 发起请求
    const response = await fetch(newRequest);
    const newResponse = new Response(response.body, response);
    
    // 处理响应
    newResponse.headers.delete("Access-Control-Allow-Credentials");
    newResponse.headers.set("Access-Control-Allow-Origin", "*");
    newResponse.headers.set("Access-Control-Allow-Headers", requestHeaders ? requestHeaders : "*");
    newResponse.headers.set("Access-Control-Allow-Methods", requestMethod ? requestMethod : "*");
    newResponse.headers.set('Access-Control-Expose-Headers', '*');
    newResponse.headers.set("Content-Type", "application/json");
    return newResponse;
}

addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request));
})

而需要用到凭据(Credentials)的情况下:

async function handleRequest(request) {
    const { origin, pathname, searchParams } = new URL(request.url);
    const param = searchParams.toString() ? "?" + searchParams.toString() : "";
    const requestHeaders = request.headers.get("Access-Control-Request-Headers");
    const requestMethod = request.headers.get("Access-Control-Request-Method");
    const requestOrigin = request.headers.get("Origin") ? request.headers.get("Origin") : origin;
    
    // 响应预检请求
    if (request.method == "OPTIONS") {
        return new Response('{"Access": "OPTIONS"}', {
            status: 200,
            headers: {
                "Access-Control-Allow-Origin": requestOrigin ? requestOrigin : "*",
                "Access-Control-Allow-Credentials": "true",
                "Access-Control-Allow-Headers": requestHeaders ? requestHeaders : "*",
                "Access-Control-Allow-Methods": requestMethod ? requestMethod : "*",
                "Content-Type": "application/json"
            }
        });
    }
    
    // 处理正式请求
    const newRequest = new Request("https://api.uptimerobot.com" + pathname + param, request);
    // 请求头删除来源
    newRequest.headers.set("referrer-policy", "no-referrer");
    newRequest.headers.delete("Referer");
    newRequest.headers.delete("Origin");
    // 发起请求
    const response = await fetch(newRequest);
    const newResponse = new Response(response.body, response);
    
    // 处理响应
    newResponse.headers.set("Access-Control-Allow-Origin", requestOrigin ? requestOrigin : "*");
    newResponse.headers.set("Access-Control-Allow-Credentials", "true");
    newResponse.headers.set("Access-Control-Allow-Headers", requestHeaders ? requestHeaders : "*");
    newResponse.headers.set("Access-Control-Allow-Methods", requestMethod ? requestMethod : "*");
    newResponse.headers.set('Access-Control-Expose-Headers', '*');
    newResponse.headers.set("Content-Type", "application/json");
    return newResponse;
}

addEventListener('fetch', event => {
    event.respondWith(handleRequest(event.request));
})

除了 RefererOrigin 需要根据实际情况进行修改以外,基本上可以适配大部分 API 了。

总结

关于跨域请求比较难的地方主要是带凭据(Credentials)的处理,在前后端分离的情况下,后端 Cookie 的写入也是一个比较棘手的问题,需要注意 Cookie 的生效范围与安全策略之类的,暂时不在本文的讨论范围内(我不会)。

对了上面拿 UptimeRobot 举例了,所以顺便提一下,关于 UptimeRobot 的完整反向代理可以看我哒这个项目:https://github.com/julydate/UptimeRobot-Proxy-Workers

然后也有别人的一些 CloudFlare Workers 反向代理方案,比如同样反向代理 UptimeRobot 的 uptime-status,还有 PikPak 的,都可以参考参考。

最后提醒一个,有时候出现跨域请求错误并不一定是后端没有配置好允许跨域,可能是因为后端报错了(HTTP Code 4xx 或者 5xx 的情况),这个时候就需要仔细排查了,而不要被跨域请求报错带到坑里以为是跨域没有配置好(算是我以前遇到的坑坑之一吧)。

参考文档:《MDN Web Docs