货都到了,海关却不放行

服务器把数据箱送到了门口,JavaScript 伸手去接,浏览器海关却“啪”地落下闸门:“来源不同,先看通关许可!”货都运回来了,怎么还算失败?

分享
货都到了,海关却不放行

箱子就在眼前

本文承接上一篇:《URL 第一次出门问路》

“放下那箱数据!”

返程通道的警报一响,JavaScript 老大停在了半空。

准确地说,是一只手停在半空。另一只手还稳稳端着咖啡。

通道里,一只刚从服务器回来的数据箱正冒着热气。浏览器海关挡在箱子前,闸门落得又快又响,差点夹住 JavaScript 老大的袖口。

“服务器给我的!” 老大指着箱子。

“知道。”

“地址也是我填的!”

“看见了。”

“HTTP 刚才还说服务器回了 200!”

“挺顺利。”

JavaScript 老大把咖啡放下:“那为什么不让我拿?”

海关翻开来源登记表:“因为发请求的页面住在这里:”

https://app.example.com

“数据箱从这里回来:”

https://api.example.com

“不是一处。”

“都叫 example.com,怎么就不是一家?”

“大衣颜色一样,就能共用门禁卡?”

“不能。”

“那就别攀亲戚。”

三样对上才算自己人

浏览器海关把两张地址卡推进检查机。

机器“咔哒”一声,吐出三枚印章:

协议    主机    端口

“判断两个地址是不是同一个源,主要核对这三样。” 海关先敲协议,“httphttps 不同源。”

又敲主机:“app.example.comapi.example.com 不同源。”

最后敲端口:“前两样相同,端口不同,照样不同源。”

JavaScript 老大拿出两张新卡:

https://app.example.com/news
https://app.example.com/settings

“路径不一样。”

“路径不参加这轮认亲。” 海关盖下“同源”章,“协议、主机、端口相同,就属于同一个源。”

老大又写了一张:

https://app.example.com:443/profile

海关瞄了一眼:“https 的默认端口就是 443。没写出来,不等于没有。”

“这规矩谁定的?”

“同源策略。”

海关指向身后的数据仓库。里面放着登录信息、私密页面和各种接口响应。

“要是任何网页里的脚本都能随便读取别处的数据,刚打开一个陌生页面,网上邻居就可能跑去翻其他站点的抽屉。”

JavaScript 老大往仓库看了一眼:“所以先把不同源都当外人。”

“先隔开。真要合作,再办手续。”

请求都发了,还算失败?

JavaScript 老大绕着数据箱走了一圈。

“有件事还是说不通。既然不同源,为什么不在出发时拦住?现在服务器已经收过请求,连响应都送回来了。”

海关拍拍箱盖:“谁说同源策略会把所有跨源请求都锁死在家里?”

“不然呢?”

“网页平时照样能加载许多来自别处的图片、样式和脚本。至于 fetch 这类由脚本发起的跨源读取,要按 CORS 规则检查响应能不能交给脚本。”

“CORS?”

“跨源资源共享。说白了,就是服务器给海关的放行说明。”

海关调出刚才的运输记录:

fetch("https://api.example.com/profile")
    .then((response) => response.json())
    .then((profile) => console.log(profile));

“这趟请求已经发出。浏览器还在请求里带了一张来源卡:”

Origin: https://app.example.com

服务器返回数据时,却没有说明是否允许这个来源读取。

JavaScript 老大拍了拍箱子:“所以东西到了,海关也看过了,但最后不给我?”

“对。在脚本眼里,这次 fetch 会失败,响应内容拿不到。”

“那服务器已经做过的事呢?”

“照常算数。海关拦的是响应,可不会跑去服务器把事情撤销。”

老大的手又停在了半空。

“所以不能拿 CORS 当服务器的防盗门?”

“总算问到重点了!” 海关在箱子上贴了一张醒目的便签,“CORS 主要限制脚本读取响应,不保证请求一定没有到达服务器。涉及改资料、扣库存这类操作,服务器仍然要自己检查身份、权限和请求是否合法。”

“箱子看得见,内容拿不到,事还可能已经办了。”

“这回记得很顺口。”

这次,许可跟着箱子来了

HTTP 抱着文件夹从服务器方向跑来。

“服务器那边把通关文件配好了!”

JavaScript 老大指着被扣下的数据箱:“贴到这只上面?”

海关一把按住箱盖:“想什么呢?这份响应回来时没有许可,不能事后贴张纸就重新开放。再发一次请求!”

JavaScript 老大只好重新填写工作单。

没过多久,一只新的数据箱回到返程通道。这一次,响应单上多了一行:

Access-Control-Allow-Origin: https://app.example.com

“这是什么意思?” JavaScript 老大探头。

“服务器明确表示:允许来自 https://app.example.com 的页面读取这份响应。”

海关把响应里的许可和请求里的 Origin 对了对。

完全一致。

闸门升起。

JavaScript 老大终于抱到数据箱,差点当场亲它一口。

“等等!” HTTP 往后退了两步,“亲箱子不在运输协议里。”

“高兴一下。”

“服务器要是愿意对不带凭证的任意来源开放公共数据,也可以这样写:”

Access-Control-Allow-Origin: *

JavaScript 老大眼睛一亮:“万能通行证!”

海关立刻把星号卡收走:“只是允许任意来源读取适合公开共享的响应。不是给所有请求发万能钥匙,更不能见接口就贴。”

“这个许可要写在请求里,还是响应里?”

“响应里。” HTTP 把文件夹抱紧,“允许谁读取,由提供资源的服务器表态。前端往请求里手写一个 Access-Control-Allow-Origin,相当于访客自己给自己盖章。”

“那我盖圆一点呢?”

“圆一点也是假章。”

普通件直接查,特殊件先敲门

新的数据箱刚放行,出发通道又响了。

这次准备出发的是一辆手续格外多的货车。

车头挂着 PUT,车厢贴着 Content-Type: application/json,顶部还竖着一面 Authorization 小旗。

海关远远看见,立刻按下红色按钮。

“先别发正件,派预检员!”

一名瘦小的 OPTIONS 从侧门钻了出去。

JavaScript 老大看愣了:“刚才的 GET 直接去了,这次怎么先派个空手的?”

“有些跨源请求方法和请求头比较简单,符合 CORS 安全清单,可以直接发送,再检查响应许可。” 海关指着货车,“这辆不在简单那一档。正式出发前,浏览器得先问服务器:这个来源想用 PUT,还想带这些请求头,行不行?”

OPTIONS 抵达服务器,递出一张询问单:

Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

服务器没有收正式货物,只回了一份预检答复:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: authorization, content-type

海关逐项核对。

“来源,允许!”

“方法,允许!”

“请求头,也允许!”

红灯变绿,货车这才载着真正的请求出发。

JavaScript 老大戳了戳刚回来的 OPTIONS:“所以你只是探路,不负责把正式数据送过去。”

“对。” OPTIONS 擦擦汗,“预检通过,正式请求才出门;预检失败,正件留在原地。”

“每次都要跑两趟?”

“服务器可以用 Access-Control-Max-Age 告诉浏览器,这份预检结果允许缓存一段时间。” 海关给预检员递了杯水,“但浏览器会有自己的上限和缓存规则,不能拿它当永久免检证。”

OPTIONS 一口喝完:“谢谢。再跑几趟,我就要申请改名叫 EXHAUSTED 了。”

带着 Cookie,星号就失灵

JavaScript 老大正准备收起通关单,门外又挤进来一群小圆饼。

每只都背着行李,有的还系着安全带。

“我们也要跟请求走!”

海关脸色一变:“谁把 Cookie 放进来的?”

“下一篇的队伍提前排到了这里。” HTTP 说。

JavaScript 老大举起工作单:

fetch("https://api.example.com/account", {
    credentials: "include"
});

“我要跨源请求时带上凭证。服务器已经写了星号,可以走吧?”

“不可以!” 海关把 * 从通行证上划掉,“请求带凭证时,Access-Control-Allow-Origin 不能用星号糊弄,服务器必须明确写出获准的来源。”

HTTP 赶紧重开一张响应单:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

“一张说明允许哪个源,一张说明允许带凭证的响应交给脚本。” 海关检查完,仍然没有立刻开门。

JavaScript 老大急了:“又缺什么?”

海关低头看向 Cookie 队伍:“CORS 许可齐了,不代表每只 Cookie 都能跟着走。它们还得看自己的 DomainPathSecureSameSite 等行李规则。”

排在最前面的 Cookie 把帽檐往下一压:“这部分归下一班检查员。”

“还挺会留悬念。”

“系列文章都写到第十八篇了,多少学会一点。”

海关不是修路队

闸门前终于安静下来。

JavaScript 老大把今天的通关单摊在桌上:

“协议、主机、端口都相同,算同源。”

海关点头。

“不同源的脚本想读取响应,要看服务器给不给 CORS 许可。”

海关继续点头。

“符合安全清单的跨源请求可以先发,再查响应;不符合的,浏览器先用 OPTIONS 预检。”

OPTIONS 在旁边有气无力地挥了挥手。

“带凭证时,服务器要明确写允许的来源,还要给出 Access-Control-Allow-Credentials: true,不能只甩一个星号。”

Cookie 队伍齐刷刷抬头。

“最后,CORS 该由服务端正确配置。浏览器海关只按双方文件办事,不负责替服务器签字。”

海关满意地收起印章:“可以下班了!”

JavaScript 老大抱着数据箱往执行大厅走,走到一半又回头:“开发时把浏览器的安全检查关掉,算解决跨域吗?”

海关沉默了两秒。

“这不叫解决跨域,叫嫌检查麻烦,直接把海关拆了。”

“听起来不太适合上线。”

“也不适合拿来证明配置没问题。”

JavaScript 老大迅速划掉这个方案。

这时,Cookie 队伍已经在另一条通道排成了长龙。

检查员拿起扩音器:“去 api.example.com 的站左边,只匹配 /account 的往前一步,没系 Secure 安全带的先别挤!”

队尾一只 Cookie 踮起脚尖:“都是 Cookie,为什么有的能上车,有的不能?”

海关把扩音器递给下一班检查员。

“问得好!开始查行李。”