💰 点进来就是赚到知识点!本文带你复习跨域知识,点赞、收藏、评论更能促进消化吸收!
转载请联系作者取得授权。
跨域报错?老夫点点按钮一把梭!
早上听到两个同事在 review 代码。
同事 A:“这个 webpack 工程,我看你把本地端口从 80 改成了 801,是为啥?”
同事 B:“这个需求涉及俩工程,我得同时跑起来开发,都用默认 80 端口会冲突。”
同事 A:“不用 80 端口,调 Server 接口不就跨域报错了吗?”
没错,这是比较容易遇到的工程问题。我们在本地启动多个前端工程时,必然只能有一个幸运鹅抢到 80 端口。而那些使用非 80 端口的工程,在请求服务端接口的时候,浏览器控制台就会报跨域错误。虽然工程跑起来了,然而业务功能完全无法调试。
但这可难不倒身为金牌前端的我。我从屏幕后面探出头,得意地说:“不会还有人不知道 Allow CORS 这个浏览器扩展吧?只要点一下按钮,轻轻松松绕过跨域问题!”
安装 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:80 | a.com, a.com |
a.com/page/xxx, a.com/api/yyy | a.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」:
似乎和一般的跨域报错有些不同:
从浏览器的 Network 面板中,也能看到有些请求总是伴随着一个 OPTIONS 请求:
这一对请求的路径完全相同,但是代码里明明没有发起这个 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 请求,请求路径相同,请求头包含 Origin
、Access-Control-Request-Method
、Access-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
的问题,并顺带收获了一枚新的、红红的跨域报错:
啊?一顿操作咋又回到原点了?
细看报错信息,我发现原来当带着 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-Origin
是 https://壳.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 技术带给我太多乐趣。如果你也和我一样,欢迎关注、私聊!