skip to content
usubeni fantasy logo Usubeni Fantasy

讲透同源策略

/ 12 min read

This Post is Available In: CN EN ES JA

前端新手的痛

我愿称同源策略(Same-origin policy,以下可能会缩略为 SOP)为前端新手的痛。

先简单说说什么是同源:同协议、同 host、同端口视为同源(Same-origin)。以 http://example.com:80 为例,协议是 http,host 是 example.com,端口是 80

访问不同源的资源会有一些奇奇怪怪的限制,让我们下面一起细数这些情况。

限制

Canvas 污染

Tainted(write-only) canvas,不能再从 canvas 中取出图像,类似情况也出现在 webGL 资源加载:

Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.

iframe 内部信息获取

访问 iframe 内的绝大部分信息都会遭到拒绝:

Uncaught DOMException: Blocked a frame with origin "http://localhost:5000" from accessing a cross-origin frame.

Ajax 请求失败

最后,也是大家最熟悉的 Ajax 请求失败:

Access to fetch at 'https://www.baidu.com/' from origin 'http://localhost:5000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

无限制

但即便同源策略带来诸多限制,依然有一些方法能让我们使用不同源的资源:

  • 链接、重定向、表单提交
  • <script src="…"></script>
  • <link rel="stylesheet" href="…">
  • <img><video><audio><object><embed> 标签
  • @font-face 应用的字体
  • iframe 网页加载(可以通过 X-Frame-Options 阻止)

但是为什么呢?如此严格的同源策略为什么却能通过上面的方法获取数据?其实究其本质——

本质

其实就是所有无限制的情景都只能获取数据,但不能修改数据。正所谓,可远观而不可亵玩焉。

上面提到的所有方法,都仅仅为获得只读,你得到的东西均不能修改,iframe 打开页面可以看到完整页面,但是绝不能插足这个页面程序的运行。

其中 form 的 action 是一个历史遗留问题,看似它用 post 提交数据是一个修改操作,但是实际上,它不能访问返回的数据。而且提交之后会直接跳转到目标网址,和链接、重定向属于一类。想当年 php 的时候这个东西没少用。

所以对于 ajax 请求,本质也是对非同源资源不可写入(opaque response)。你的请求是成功的,但是浏览器拒绝让你操作获取的数据。换言之,跨域请求结果的拦截是浏览器行为。

为何存在

知道本质就很容易反推 SOP 为什么存在啦:

  • 网络安全问题,主要防止跨站请求伪造 CSRF
  • 防止宿主对 iframe 肆意操作
  • 禁止客户端肆意编辑非同源图片音视频文件

应对方案

SOP 带来安全的用户体验,但是开发体验却不怎么好,开发者需要付出额外努力应对 SOP。

CORS 协议

To allow sharing responses cross-origin and allow for more versatile fetches than possible with HTML’s form element, the CORS protocol exists. It is layered on top of HTTP and allows responses to declare they can be shared with other origins.

CORS 协议用于协商服务器资源是否能被异域读取。

没错,CORS 是一个协议,一个基于 http 协议的协议,通过 http 请求头、响应头实现,具体流程为:

  1. 遇到跨域请求、且非简单请求(否则直接发送真实请求)
  2. 发送 preflight 请求
  3. 返回服务器返回 CORS 配置信息
  4. 浏览器判断是否允许发送真实请求
  5. 发送真实请求,真实请求的响应头也会包含 CORS 配置信息,所以浏览器会允许开发者使用响应数据(如果是简单请求的话会直接发送,这时候检测配置信息失败的话就会报跨域错误)

preflight 请求(常被翻译为预检请求),是一个 OPTIONS 的 http 请求,关键请求头是下面这两个:

  • Access-Control-Request-Method:真实请求的 method
  • Access-Control-Request-Headers:真实请求包含的 header

CORS 协议会通过 http 响应头返回允许跨域的条件,从命名上我们基本可以了解这些响应头在 CORS 协议中的功能:

  • Access-Control-Allow-Methods:允许的 method
  • Access-Control-Allow-Headers:允许的 header
  • Access-Control-Allow-Origin:允许访问的源
  • Access-Control-Allow-Credentials:是否允许访问时带凭证
  • Access-Control-Max-Age:上两个信息的缓存时间
  • Access-Control-Expose-Headers:JavaScript 可以读取的响应头

那么为什么需要 preflight 请求?

我的理解是,如之前说的,浏览器的行为是拦截结果,请求依旧会成功发送到服务器,正常执行逻辑,这样就太危险了,但是有了 preflight 请求,在真实请求前就拦住,不会成功请求到服务器。另一方面,CORS 协议并不阻止简单请求到达服务器,应该是因为 get 方法不会带来数据修改,所以难以引发严重后果,再加上可能有历史原因,便放过了。

下面展示一个 CORS 协议服务器端的简易实现:

fastify.addHook("preHandler", (req, res, done) => {
const allowedPaths = ["/cors-simple", "/cors"];
console.log(`\n${req.method}: ${req.url}\n`);
if (allowedPaths.includes(req.url)) {
res.header("Access-Control-Allow-Origin", "http://127.0.0.1:3000");
res.header("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE");
res.header("Access-Control-Allow-Headers", "content-type,custom-header");
res.header("Access-Control-Allow-credentials", "true");
}
const isPreflight = /options/i.test(req.method);
if (isPreflight) {
return res.send();
}
done();
});

更成熟的写法可以参考 fastify-cors

CORS 协议协商好了,浏览器就会允许 JavaScript 获取异域数据。但问题并没有这么简单地被解决(这就是我为什么说开发体验真不太好),即使可以读取异域数据,在 cookie 等请求凭证上依然存在问题:

fetch 方法本身默认不携带跨域 cookie,也不会设定响应的 set-cookie,必须添加设定 credentials: "include",但我们要做的远不止于此,后续还需要:

  • 添加 Access-Control-Allow-Credentials 后,向异域发出的请求允许携带凭证
  • 但配置 Allow-Credentials 后,Access-Control-Allow-Origin 不能为 *,必须是单个 Origin
  • 必须是 SameSite=None 的 cookie 才能被发送到服务器
  • SameSite=None 时,Secure 也是必要的
  • 必须是 https 协议,否则 Secure cookie 写不进去

P.S. same site 和 same origin 有细微差别,但大多数情况下不需要考虑,详情可参考:The great SameSite confusion

从 chorme80(2020.02)开始引入 headers HTTP header: Set-Cookie: SameSite: Defaults to Lax,这时用户会感受到升级之后登陆不上,就是因为跨域 Cookie 默认不发送了。

顺带一提,与 CORS 类似作用的请求头还有 CORP,这个请求头用于禁止 <script><img> 等资源引用,默认值为 cross-origin

Today, browsers act as though Cross-Origin-Resource-Policy: cross-origin is set on every response that lacks an explicit CORP header.

洁净的 Canvas

CORS 协议也适用于解决图片被 canvas 读取的情况,默认情况下图片请求方式不是 CORS,需要添加 img.crossOrigin = 'anonymous'(使用 js 添加时注意驼峰,全小写不生效,但加在 html 标签的时候是全小写)改变请求方式。

跟原来不同的是,本来 canvas 可以直接显示跨域图片,但是添加 crossOrigin 后必须是已经添加 CORS 请求头的资源才能顺利请求,否则直接请求报错,图片不会显示

Access to image at 'https://image.api.playstation.com/trophy/np/NPWR13281_00_00A03E8F7ED2727FADE2548E45F2781D32F5D048F6/B81B1B7DBEB337F763D736123661E1D0E8B59FEE.PNG' from origin 'http://localhost:5000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

顺利通过跨域请求的图片即可在不污染 canvas 的情况下加入到 canvas。

反向代理

除了添加 CORS,你还可以通过反向代理将原本异域的接口服务和网络应用文件整合为同域。

开发环境可以使用大家都很熟悉的 Webpack devServer.proxyVite 的 Server Options

而在生产环境中常见的反向代理有 nginx。

postMessage

postMessage 主要解决不同 window(例如不同 iframe)间的数据交流:

发送方

targetWindow.postMessage(message, targetOrigin, transfer);

发送方要使用 postMessage 传信息到另一个窗口,首先要拿到目标窗口window 变量,例如:iframe 的话可以 querySelector 后访问 contentWindow 属性;window.open() 方法则会直接返回目标窗口的 window 对象,等等。

第一个参数是发送的数据,会被深克隆,同时,我们还可以通过控制第二个参数 targetOrigin 确保目标窗口的 origin 是你指定的值。

实际使用时可能会是这样的:

main.html
iframe.contentWindow.postMessage({ jsondata: {}, 1: "hello" }, "http://localhost:3000");

接收方

sub.html
window.addEventListener("message", (event) => {
if (event.origin === "http://127.0.0.1:3000") {
console.log("pass");
}
});

这种方式可行就是因为这在双方都需要添加编码,我们就可以通过一些约定确保交流的双方是可信的。最简单的方法是通过 event.origin 确定信息来源是可信来源,否则很容易遭到攻击。

websocket

websocket 可以绕过同源策略,但是对服务器负荷大。所以不会有人为了绕过同源策略使用 websocket 代替 http 接口。

JSONP

奇技淫巧,时代的眼泪,简而言之是钻 JavaScript 文件可以随意跨域的空子,返回一个函数包裹的数据,然后就能用你定义的函数读取数据了。总之,现在应该是没人用了。

总结

  • 同协议、同 host、同端口视为同源
  • 同源策略(SOP)的本质是保证非同源信息只读
  • SOP 为用户提供了更安全的网络环境
  • 现时开发者应对跨域限制的主要方式是 CORS 协议
  • 前端开发者在 CORS 的基础下仍需要处理跨域 cookie 问题
  • 跨域 iframe 交流可使用 postMessage

参考

P.S. 因为浏览器的高速更新,文中提及的同源策略可能会出现变化,请仔细甄别

评论组件加载中……