快来嘲笑这个人,他一天被「跨域」绊倒好几次

794 阅读11分钟

💰 点进来就是赚到知识点!本文带你复习跨域知识点赞收藏评论更能促进消化吸收!

转载请联系作者取得授权。

跨域报错?老夫点点按钮一把梭!

早上听到两个同事在 review 代码。

同事 A:“这个 webpack 工程,我看你把本地端口从 80 改成了 801,是为啥?”

01.png

同事 B:“这个需求涉及俩工程,我得同时跑起来开发,都用默认 80 端口会冲突。”

02.png

同事 A:“不用 80 端口,调 Server 接口不就跨域报错了吗?”

03.png

没错,这是比较容易遇到的工程问题。我们在本地启动多个前端工程时,必然只能有一个幸运鹅抢到 80 端口。而那些使用非 80 端口的工程,在请求服务端接口的时候,浏览器控制台就会报跨域错误。虽然工程跑起来了,然而业务功能完全无法调试。

但这可难不倒身为金牌前端的我。我从屏幕后面探出头,得意地说:“不会还有人不知道 Allow CORS 这个浏览器扩展吧?只要点一下按钮,轻轻松松绕过跨域问题!”

04.gif

安装 Allow CORS 后,只需要打开开关,之前被跨域报错拦截的请求立马畅通无阻。剩下的就是一套丝滑连招:干需求、提测、下班!

同事:你是这个 👍🏻

(虽然一股电视购物的味儿,但确实不是广子)

帅不过三秒,老老实实补课吧

中午,我接了个大活儿。

团队内部署了一套 Dify + Langfuse 服务,用来跑一些 AI 工作流。我的任务是在不占用后端研发人力的前提下,搭建一个管理后台,把 Dify 和 Langfuse 各自的独立后台功能整合到一起。别问为啥不能占用后端人力,问就是全干工程师。而且 Langfuse 提供的 JS SDK 功能不全,比如后台有个创建打标队列的功能,在 SDK 文档中根本找不到对应的 API。嘶…… 要啥没啥,就没打过这么不富裕的仗。

就在考虑是否要呼叫后端增援时,我突然灵机一动 —— Langfuse 自带的后台里是有这些功能的,那我直接用里面的现成接口不就行了?管它 SDK 不 SDK 的,我直接扒过来套上自己的前端壳。套壳在 AI 时代一点都不新鲜,壳我就是这么真(狗头)。

秉承着按时下班的信念感,我开始扒接口、搬运数据结构。等到开始调试第一个接口时,我发现事情不对劲了。

为了方便表述,我们假设 Langfuse 自带的后台接口是 https://langfuse.cms.com/api/xxxx,而套壳 CMS 的域名是 https://壳.cms.com

想必各位金牌前端也都看出来了,在套壳 CMS 里调用 Langfuse 后台接口,会导致跨域报错,直接就是一个调不通。

咋整?我的视线滑到了 Allow CORS 上。本地开发还能用它应付应付,那部署上线后呢?难不成要把套壳 CMS 和浏览器扩展捆绑使用?真这么干,估计我和失业名额也要捆绑了。

唉,叉腰安利 Allow CORS 的时候太年轻,不知道所有命运馈赠的礼物,早已在暗中标好了价格。

看来没办法取巧了,只能硬着头皮正面硬刚跨域了。

世界上为什么要存在跨域这种东西?

你自己的房间,你肯定不希望陌生人随意出入,对吧?网络资源也是一样的,如果没有限制,就会被随意滥用。而且还会滋生网络安全隐患,比如 CSRF 攻击 —— 你在浏览器登录了掘金后,别人在非掘金网页也能用你的身份调用掘金接口干坏事,那世界就乱套了。

于是在 2014 年,W3C 通过了一项名为 Cross-Origin Resource Sharing 的提案,建立一套机制来限制跨源资源访问。这套机制就是我们常说的 CORS,通过一系列请求头和响应头规则来建立控制机制

其中源(Origin)用来作为网络资源自报家门的标识,由协议(Protocol)、域名(Host)、端口(Port)构成。如果两个源的这三个部分完全相同,才算是同源。

同源示例跨源示例
a.com, a.com:80a.com, a.com
a.com/page/xxx, a.com/api/yyya.com, sub.a.com
a.com:8080, a.com:8888

注意:80 是默认端口,可省略表示。

当发生跨源访问时,浏览器会和服务端逻辑共同配合,来为资源访问把关。如果请求方符合跨源条件,就放行,反之则会被拦截。这样就能防止滥用、保证安全。

跨域报错的现场到底发生了什么?

假设我在开发 https://壳.cms.com/user.html 这个页面,页面中调用了 https://langfuse.cms.com/api/userInfo 这个接口:

fetch('<https://langfuse.cms.com/api/userInfo?id=JaxNext>')

在这个请求的请求头中,浏览器会添加 Origin: <https://壳.cms.com> 。

默认情况下,Langfuse 服务接收到请求后返回数据,没有其他的额外处理;当响应数据到达浏览器后,浏览器一看:”咦,页面和 HTTP 请求根本不同源,我眼里可不揉沙子!“于是当场扣下响应数据,并且抛出一个报错信息。

也就是说,这个 HTTP 请求完整地打了一个来回,只是在家门口被浏览器拦截下来了,我们无法通过 JavaScript 获取到响应内容。如果使用 ApiFox 或者 PostMan 发送这个请求,你会发现接口是正常返回数据的。

Preflight 又是咋回事儿?

仔细观察跨域报错的内容,里面提到了「preflight request」:

05.png

似乎和一般的跨域报错有些不同:

06.png

从浏览器的 Network 面板中,也能看到有些请求总是伴随着一个 OPTIONS 请求:

07.png

这一对请求的路径完全相同,但是代码里明明没有发起这个 OPTIONS 请求啊?

原来这也是 play 的一环,是浏览器的 Preflight 机制。

如果一个 HTTP 请求简简单单、清清爽爽,没有自定义的请求头字段,也没有特殊的 Content-Type 值,那么它可以算是「简单请求」,浏览器会直接发送出去。

但如果一个 HTTP 请求有一些特殊设置,比如:

fetch('<https://langfuse.cms.com/api/userInfo?id=JaxNext>', {
  headers: {
    'X-Api-Key': '123456',
    'Content-Type': 'text/html',
  }
})

这些特殊设置可能在服务端产生影响,比如提交了恶意信息上去,那么仅仅在浏览器这一头拦截响应也无济于事了。为了避免这种情况,浏览器会为这样的请求配备一个探路的 OPTIONS 请求,请求路径相同,请求头包含 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers 等 CORS 信息;而服务端会返回一个 204 no content,响应头会携带 Access-Control-Allow-Method 等放行规则。

浏览器收到 OPTIONS 响应后,根据放行规则判断,如果同源,再把原本的请求发送出去;否则就直接拦截下来不发送,并抛出一个 Preflight CORS 的报错。

怎么解决跨域问题?

我们在本地开发时,一般会启动一个本地的 devServer,例如 http://localhost:3000,然后配置一层代理,在 Node.js 层调用 https://langfuse.cms.com/api/userInfo 接口,拿到响应数据后再返回给浏览器。从浏览器的角度看,页面和请求的源都是 http://localhost:3000 ,既然是自己人,也就可以随意出入了。

此外,还可以使用 Whistle 或 Allow CORS 等工具来绕过跨域问题。

但是在生产环境,页面部署到了 https://壳.cms.com 上。我们显然不可能在用户的电脑上施展这些小手段,要想成功拿到响应数据,就得从 Langfuse 服务接口这一端动手。

万幸万幸,Langfuse 自带后台是一整套 Next.js 服务,我只需要在接口服务里改几行 JavaScript 代码即可。

针对上述示例,我们需要在响应头里配置跨域规则,如:

// 服务端接口代码
...
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE')
    ...

这样,当浏览器接收到响应数据,从响应头里读取到 Access-Control-Allow-Origin: *,意思是无论来自什么源的页面都无需限制,也就不会抛出跨域错误了。

更改代码后,构建并重启 Docker 镜像,这时再刷新 https://壳.cms.com/user.html 页面,你就不会看到跨域报错了。

但紧接着我又有了新的课题:接口报 401

带 credentials 的跨域问题怎么解决?

上面提到过,跨域拦截只是发生在浏览器里,但如果我们用 HTTP 请求工具或者 Node.js 脚本来发起请求,还是能拿到接口响应的,并不能杜绝资源滥用和网络攻击。

于是对于需要保护的网络资源、服务,开发者会加上鉴权机制,你需要有合法的用户身份,接口才返回你要的数据。

而我上面调用 Langfuse 接口,由于没有在请求头里带上包含登录身份的 Cookie,导致接口鉴权认为我是个游客,用 401 状态码拒绝为我服务。

那么我的请求都应该这么调用:

fetch('<https://langfuse.cms.com/api/userInfo?id=JaxNext>', {
  credentials: ‘include’,
})

这次发起请求,就携带了 Cookie 一起寄出,解决了接口报 401 的问题,并顺带收获了一枚新的、红红的跨域报错:

08.png

啊?一顿操作咋又回到原点了?

细看报错信息,我发现原来当带着 credentials 发起请求时,服务端配置的 Access-Control-Allow-Origin 规则就不能是通配符 * 了,这样显得太过于狂野轻浮,必须稳稳当当地指定一个确定的源;另外 Access-Control-Allow-Credentials 也要配置为 true。于是改成:

// 服务端接口代码
...
response.setHeader('Access-Control-Allow-Origin', '<https://壳.cms.com>') // 改这里
response.setHeader('Access-Control-Allow-Credentials', 'true') // 新增这一行
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE')
...

又是一套构建、重启 Docker 镜像的丝滑连招,请求终于调通了。

早上安利,晚上回旋镖打脸

晚上,我把套壳 CMS 部署到测试环境,在自己电脑上自测了一遍没啥问题,就跟需求方说可以验收了。

正准备下班,验收同事说:“祸事了祸事了!CMS 报跨域错误了!”

我涨红了脸,额上的青筋条条绽出,争辩道:“不可能,在我电脑上是好的呀!”

公司内充满了快活的空气……

原来,测试环境的域名是 https://壳.test.cms.com,而 Langfuse 接口配置的 Access-Control-Allow-Originhttps://壳.cms.com,确实跨域了。

为了支持动态的放行规则,代码可以这么改:

// 服务端接口代码
...
const list = [
  '<https://壳.cms.com>',
  '<https://壳.test.cms.com>'
]
const origin = request.headers.origin
if (list.includes(origin)) {
    response.setHeader('Access-Control-Allow-Origin', origin)
}
...

再来一遍构建、重启 Docker 镜像,总算通过了验收。

可是为啥我自测的时候没问题呢?经过好一顿排查,您猜怎么着?因为我浏览器的 Allow CORS 扩展没关,自动把跨域问题绕过去了……

为什么浏览器扩展就可以绕过跨域问题?

为了降低低级错误带来的耻辱感,我研究了一下 Allow CORS 解决跨域的原理。

在 Chrome 的 Manifest V3 API 中,可以通过配置规则来为 HTTP 响应自动注入头信息,大致做法如下:

manifest.json:

...
"permissions": [
    ...
    "declarativeNetRequest"
  ],
  "declarative_net_request": {
    "rule_resources": [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules.json"
    }]
  },
...

rules.json:

[
  {
    "id": 1,
    "priority": 1,
    "action": {
      "type": "modifyHeaders",
      "responseHeaders": [
        {
          "header": "Access-Control-Allow-Origin",
          "operation": "set",
          "value": "*"
        }
      ]
    },
    ...
  }
]

这样,当浏览器扩展运行时,浏览器中 HTTP 响应头里就都被设置了跨域放行,也就没有拦截和报错了。

早上还在那叭叭给人安利呢,没想到中午就老实了,晚上更是啪啪打脸。人生的大起大落,就这样被跨域浓缩在了同一天中。

程序员的肚儿,杂货铺儿

以前自己总觉得,只要专注于 CSS 运用和 JavaScript 编程就够了,像跨域这类问题,由服务端去解决也好,由 devServer、Allow CORS 这些工具去解决也好,都不是我该操心的范畴。

现在看来,这样想还是显得稚嫩了。不管是前端、后端,还是网络、算法,只有不停地拓宽认知广度,才能持续地提升解决问题的能力和效率。之前偷的懒,之后可能会花成倍的时间和精力去弥补。学吧,都是知识。

📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注私聊