同源策略

同源策略限制了一个源的 document 和 script 如何与另一个源交互。
源是指协议域名端口,同源指两个 URL 的源相同

同源策略主要限制以下三种操作

DOM 操作

首先要知道,在浏览器打开两个网页(标签/窗口),在同源时是可以相互交互的:
以本博客首页为例:源都为 https://imbant.github.io/,在首页 /blog/ 打开控制台,调用 window.open 打开 /blog/archives/ 到归档,可见:
window.open 会返回被打开页面的 Window 对象,而被打开的页面 window.opener 指向前一个页面的 Window 对象。
img

这两个暴露在其他页面的 Window 对象,是可以正常访问,执行内部方法并影响到真正的页面的:
img

因此可以进行 DOM 操作:
img

非常危险的操作。试想如果没有同源限制,打开一个钓鱼网站和银行官网,钓鱼网站可以随意获取账号密码信息。

这类允许文档间直接相互引用的 API,有

  • window.open() 返回被打开的 Window
  • window.opener 返回打开当前窗口的那个 Window
  • window.parent 返回当前窗口的父 Window,如果当前窗口是一个 <iframe><object>, 或者 <frame>,则它的父 Window 是嵌入它的那个 Window
  • iframe.contentWindow 返回 iframe 的 Window

在实际操作中,浏览器提供了 window.postMessage更安全的实现跨域的跨文档消息机制

数据操作

无法访问不同源页面的 cookie indexDB localStorage

XMLHttpRequest 操作

普遍情况下,XHR 请求只可以对同源站点

XHR 跨域

为了安全性,XHR 需要牺牲便利性,来发起跨域请求。

CORS(Cross-Orign Resource Sharing)

CORS 是一个 W3C 标准,由一些 HTTP 头组成,可以让前后端共同配置,实现跨域 XHR 请求。

有两种场景:简单请求和预检请求

简单请求

简单请求需要满足:

  • 方法只能为 GETHEADPOST。其中 HEAD 用于只请求 header,并且这个 header 会和 GET 请求中的一样,用于下载大文件前提前了解大小,节省带宽。
  • 除了默认 header 之外,只可以有以下 header
    • Accept response 可选的 MIME 类型
    • Accept-Language 指定客户端可以理解的自然语言
    • Content-Language 指定用户(audience)希望的自然语言
    • Content-Type
    • 还有一些头 TODO:
  • Content-Type 只能为三者之一
    • application/x-www-form-urlencoded 通过 ‘&’ 和 ‘=’ 连接的键值对
    • multipart/form-data 表格类型
    • text/plain 原始文本

Content Type 的限制是为了兼容表单(form),历史上表单一直是可以跨域请求的。同时 <form> 标签的 enctype 属性也是这三者之一,用以标识原生表单 POST 时的 Content Type,表单不借助 js 天然就可以发出简单请求

对于简单请求,浏览器直接发出 XHR 请求,并在 header 中加入 Origin 字段,来标识本次请求的源(协议、域名、端口)。
服务端收到这个跨域 request,并且在许可的源范围内,response 中就会三个头:

  • Access-Control-Allow-Origin 这个字段是必须的,否则会视为跨域请求失败。值为 request 时的 Origin 或者 ‘*’
  • Access-Control-Allow-Credentials 可选,如果为 true 表示服务端明确许可 request 中带 Cookie,并且前一个 header Access-Control-Allow-Origin 不能为 ‘*’,必须写明源
  • Access-Control-Expose-Headers 指定客户端可以访问的 response 的 header,例如 response 中服务端自定义 header X-Customer-Header,需要指定后客户端才能在 XHR 请求对象中拿到,否则只能拿到一些基础 header TODO: 感觉很鸡肋啊,就算 JS 脚本拿不到,抓包还是能拿到没指定的 header

XMLHttpRequest 对象的请求默认不带 cookie,需要手动设置 withCredentials = true

预检请求

不符合简单请求的被称为预检(preflight)请求。
浏览器发现 XHR 请求需要预检,会先发送一个 OPTION 请求到服务端,这个请求不会对服务器资源产生影响。

用 OPTION 做预检的原因在于,节省服务端处理跨域请求的资源

预检会带上以下 header

  • Origin
  • Access-Control-Request-Method 告知实际请求的 method
  • Access-Control-Request-Headers 告知实际请求携带的自定义 header

服务端确认允许跨域后,会对预检做出 response,带上 CORS header

  • Access-Control-Allow-Origin 同简单请求
  • Access-Control-Allow-Credentials 同简单请求
  • Access-Control-Allow-Methods 允许客户端使用的 method,会返回全部支持的 method,不限于预检的,避免多次预检
  • Access-Control-Allow-Headers 允许客户端携带的 header,会返回全部支持的 method,不限于预检的,避免多次预检
  • Access-Control-Max-Age 预检有效时间,在有效时间内发跨域请求无需再次预检

通过预检后,浏览器正常的 CORS 请求都会和简单请求一样,带上 Origin,服务端也会每次回应 Access-Control-Allow-Origin

注意跨域请求失败的情况下,一般 response 状态码可能为 200,而 CORS 需要的 header 不存在,通过 header 感知跨域失败

简单请求 request 简单请求 response OPTION request OPTION response
Origin *-Allow-Origin
*-Allow-Credentials
*-Expose-Headers
Origin
*-Request-Method
*-Request-Headers
*-Allow-Origin
*-Allow-Credentials
*-Allow-Method
*-Allow-Headers
*-Max-Age

* 表示 Access-Control 前缀,方便排版

其他方式

JSONP:通过在 HTML 手动插入 <script> 标签,利用其加载资源无跨域限制的方式发送跨域请求。一般是前端全局定义一个函数,然后被请求的服务端返回一段 js 代码,去执行这个函数,并把需要的数据传给参数。缺点是只可以做 GET 请求,代码侵入性大。

本地调试时正向代理 - 服务端不知道真正发请求的客户端:node 服务转发、webpack 本地配置 devServer proxy(部署时是同源的)、Charles 代理

线上部署时反向代理 - 客户端不知道真正接收请求的服务端:nginx 负载均衡,客户端请求的是负载均衡服务器,由他转发请求到不同源

window.postMessage:见上文,更安全更受控的跨文档通信方式