再谈跨域

date
May 23, 2018
slug
cross-origin
status
Published
tags
Browser
summary
type
Post

同源策略

浏览器的同源策略是一个用于隔离潜在恶意文件的重要安全机制,它限制了不同源之间的交互,
同源策略的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。例如,cookie 往往用来保存用户的登录状态,是无法被不同源的网站所获取的。
所谓同源,是指通讯双方的协议、域名、端口皆相同,缺一不可。
http://www.example.com/dir/page.html 为例,以下表格列举了各类 URL 进行对比,阐明了同源的必要条件:
常见的,同源策略限制了一下以下三种行为:
  • 获取浏览器本地存储数据:cookie、localStorage 等
  • 获取 DOM
  • XMLHttpRequest 和 Fetch API

跨域的几种形式

同源策略保证了用户信息的安全,但同时也限制了业务的展开。非常普遍的一个例子,前后端分离这样的应用架构就需要跨域通信。那么怎么样才能绕过同源策略,实现跨域呢?

iframe

HTML内联框架元素<iframe>表示嵌套的浏览上下文,有效地将另一个HTML页面嵌入到当前页面中。显然iframe中的页面与原先主页面是属于不同域的,对于这种形式的跨域,通常有以下几种方案。
window 对象有个 name 属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个window.name的,每个页面对window.name都有读写的权限,window.name是持久存在一个窗口载入过的所有页面中的。window.name属性的神奇之处在于name 值在不同的页面(甚至不同域名)加载后依旧存在(如果没修改则值不会变化),并且可以支持非常长的 name 值(2MB)。
 window.name = data;//父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入。
 location = '<http://parent.url.com/xxx.html>';//接着,子窗口跳回一个与主窗口同域的网址。
 var data = document.getElementById('myFrame').contentWindow.name。//然后,主窗口就可以读取子窗口的window.name了
location.hash
假设域名a.com下的文件cs1.html要和jianshu.com域名下的cs2.html传递信息。 1、cs1.html首先创建自动创建一个隐藏的iframe,iframe的src指向jianshu.com域名下的cs2.html页面。 2、cs2.html响应请求后再将通过修改cs1.html的hash值来传递数据。 3、同时在cs1.html上加一个定时器,隔一段时间来判断location.hash的值有没有变化,一旦有变化则获取获取hash值。
注:由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于a.com域名下的一个代理iframe。
优点:1.可以解决域名完全不同的跨域。2.可以实现双向通讯。 缺点:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。另外由于URL大小的限制,支持传递的数据量也不大。有些浏览器不支持onhashchange事件,需要轮询来获知URL的变化。
postMessage
HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。
var popup = window.open('<http://bbb.com>', 'title');//父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法。
popup.postMessage('Hello World!', '<http://bbb.com>');
postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即"协议 + 域名 + 端口"。也可以设为*,表示不限制域名,向所有窗口发送。
父窗口和子窗口都可以通过message事件,监听对方的消息。message事件的事件对象event,提供以下三个属性:
  1. event.source:发送消息的窗口。
  1. event.origin: 消息发向的网址。
  1. event.data:消息内容。
一个例子:
 var onmessage = function (event) {
   var data = event.data;//消息
   var origin = event.origin;//消息来源地址
   var source = event.source;//源Window对象
   if(origin == "<http://www.aaa.com>"){
    console.log(data);//hello world!
   }
    source.postMessage('Nice to see you!', '*');
 };
 if (typeof window.addEventListener != 'undefined') {
   window.addEventListener('message', onmessage, false);
 } else if (typeof window.attachEvent != 'undefined') {
   //ie
   window.attachEvent('onmessage', onmessage);
 }

JSONP

JSONP 是 JSON with padding 的简写,名称看起来和 JSON 差不多,只不过它的 JSON 被包含在函数调用中,就像下面这样:
callback({"name": "Nicholas"});
JSONP 由两部分组成:回调函数和数据。回调函数是当响应到来时应该在页面中调用的函数。回调函数的名字一般是在请求中指定的,而数据就是传入回调函数中的数据。下面是一个典型的 JSONP 请求:
<http://freegeoip.net/json/?callback=handleResponse>
如 URL 所示,这里的回调函数名为 handleResponse。
如何使用 JSONP
JSONP 是通过插入动态<script>元素来使用的,使用时可以为 src 属性指定一个跨域 URL:
function handleResponse(response) {
    alert(`ip: ${response.ip} city: ${response.city} region_name: ${response.region_name}`);
}

let script = document.creatElement('script');
script.src = '<http://freegeoip.net/json/?callback=handleResponse>';
document.body.insertBefore(script, document.body.firstChild);
JSONP 简单易用,但是它也有其局限性:
  • JSONP 是从其他域中加载代码执行,如果其他域不安全,那么响应中可能会携带一些恶意代码。
  • 只能发送 GET 请求。

跨域资源共享(Cross-origin resource sharing)

什么是 CORS
CORS 是 W3C 的一个工作草案,定义了在必须访问跨域资源时,浏览器和服务器应该如何沟通。CORS 背后的基本思想,就是使用自定义的 HTTP 头部让浏览器和服务器进行沟通,从而决定请求或响应是应该成功还是失败。
CORS 标准允许在下列场景中使用跨域 HTTP 请求:
  • XMLHttpRequest或 Fetch 发起的跨域 HTTP 请求。
  • Web 字体 (CSS 中通过@font-face使用跨域字体资源), 因此,网站就可以发布 TrueType 字体资源,并只允许已授权网站进行跨站调用。
  • WebGL 贴图
  • 使用 drawImage 将 Images/video 画面绘制到 canvas
  • 样式表
  • Scripts
CORS 机制
跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
  • 简单请求
    • 某些请求不会触发 CORS 预检请求。称这样的请求为“简单请求”。若请求满足所有下述条件,则该请求可视为“简单请求”:
    • 使用下列方法之一:
      • GET
      • HEAD
      • POST
    • Fetch 规范定义了对 对 CORS 安全的首部字段集合 ,不得人为设置该集合之外的其他首部字段。该集合为:
      • Accept
      • Accept-Language
      • Content-Language
      • Content-Type (需要注意额外的限制)
      • DPR
      • Downlink
      • Save-Data
      • Viewport-Width
      • Width
    • Content-Type 的值仅限于下列三者之一:
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
    • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
    • 请求中没有使用 ReadableStream 对象。
    • 简单请求示例
      假如站点 http://foo.example 的网页应用想要访问 http://bar.example 的资源:
      var invocation = new XMLHttpRequest();
      var url = 'http:/bar.example/resources/public-data/';
      
      function callOtherDomain() {
        if(invocation) {
          invocation.open('GET', url, true);
          invocation.onreadystatechange = handler;
          invocation.send();
        }
      }
      
      若浏览器检测到该请求为简单请求,则会自动在 HTTP 请求中添加Origin字段,它用来说明请求来自于哪个源。
      本例中,服务端会返回 Access-Control-Allow-Origin: * ,这表明,该资源可以被任意外域访问。如果服务端仅允许来自http://foo.example 的访问,该首部字段的内容如下:
      Access-Control-Allow-Origin: http://foo.example
      Access-Control-Allow-Origin 应当为 * 或者包含由 Origin 首部字段所指明的域名。
  • 预检请求客户端和浏览器的交互示意图如下:
    • notion image
      与前述简单请求不同,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。
      当请求满足下述任一条件时,即应首先发送预检请求:
    • 使用了下面任一 HTTP 方法:
      • PUT
      • DELETE
      • CONNECT
      • OPTIONS
      • TRACE
      • PATCH
    • 人为设置了对 CORS 安全的首部字段集合之外的其他首部字段。该集合为:
      • Accept
      • Accept-Language
      • Content-Language
      • Content-Type (but note the additional requirements below)
      • DPR
      • Downlink
      • Save-Data
      • Viewport-Width
      • Width
    • Content-Type 的值不属于下列之一:
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain
    • 请求中的XMLHttpRequestUpload 对象注册了任意多个事件监听器。
    • 请求中使用了ReadableStream对象。
    • 预检请求示例:
      var invocation = new XMLHttpRequest();
      var url = '<http://bar.other/resources/post-here/>';
      var body = '<?xml version="1.0"?><person><name>Arun</name></person>';
      
      function callOtherDomain(){
        if(invocation)
          {
            invocation.open('POST', url, true);
            invocation.setRequestHeader('X-PINGOTHER', 'pingpong');
            invocation.setRequestHeader('Content-Type', 'application/xml');
            invocation.onreadystatechange = handler;
            invocation.send(body);
          }
      }
      
      浏览器检测到,从 JavaScript 中发起的请求需要被预检,就会先发送一个预检请求,预检请求中同时携带了下面两个首部字段:
      Access-Control-Request-Method: POST
      Access-Control-Request-Headers: X-PINGOTHER, Content-Type
      
      首部字段 Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法。首部字段 Access-Control-Request-Headers 告知服务器,实际请求将携带两个自定义请求首部字段:X-PINGOTHER 与 Content-Type。服务器据此决定,该实际请求是否被允许。
      接着,服务器将接受后续的实际请求 ,返回以下首部字段:
      Access-Control-Allow-Origin: <http://foo.example>
      Access-Control-Allow-Methods: POST, GET, OPTIONS
      Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
      Access-Control-Max-Age: 86400
      
      首部字段Access-Control-Allow-Methods表明服务器允许客户端使用POST, GETOPTIONS 方法发起请求。
      首部字段 Access-Control-Allow-Headers表明服务器允许请求中携带字段 X-PINGOTHERContent-Type。与Access-Control-Allow-Methods一样,Access-Control-Allow-Headers 的值为逗号分割的列表。
      最后,首部字段 Access-Control-Max-Age 表明该响应的有效时间为 86400 秒,也就是 24 小时。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。
notion image
  • 附带凭证信息的请求
    • 默认情况下,跨域请求不提供凭据( cookie、HTTP 认证及客户端 SSL 证明等)。通过将withCredentials属性设置为 true,可以指定某个请求应该发送凭证。
      一个例子:
      //http://foo.example站点的脚本向http://bar.other站点发送一个GET请求,并设置了一个Cookie值。脚本代码如下:
      var invocation = new XMLHttpRequest();
      var url = '<http://bar.other/resources/credentialed-content/>';
      function callOtherDomain(){
        if(invocation) {
          invocation.open('GET', url, true);
          invocation.withCredentials = true;
          invocation.onreadystatechange = handler;
          invocation.send();
        }
      }
      
      如上,第七行代码将 XMLHttpRequest 的 withCredentials 标志设置为true,从而使得Cookies可以随着请求发送。因为这是一个简单的GET请求,所以浏览器不会发送一个“预请求”。但是,如果服务器端的响应中,如果没有返回Access-Control-Allow-Credentials: true的响应头,那么浏览器将不会把响应结果传递给发出请求的脚本程序,以保证信息的安全。
      假设服务器成功响应返回部分信息如下:
      Access-Control-Allow-Origin: <http://foo.example>
      Access-Control-Allow-Credentials: true
      Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
      如果bar.other的响应头里没有 Access-Control-Allow-Credentials:true,则响应会被忽略。请注意: 给一个带有withCredentials的请求发送响应的时候,服务器端必须指定允许请求的域名,不能使用*。上面这个例子中,如果响应头是这样的 Access-Control-Allow-Origin:*,则响应会失败。在这个例子里,因为Access-Control-Allow-Origin的值是 http://foo.example 这个指定的请求域名,所以客户端把带有凭证信息的内容被返回给了客户端。另外注意,更多的 cookie 信息( expires 字段等)也被创建了。

Web Sockets

Web Sockets 的目标是在一个单独的持久连接上提供一个全双工、双向通信。在 JavaScript 创建了 Web Socket 之后,会有一个 HTTP 请求发送到服务器以发起连接,在取得服务器响应后,建立的连接会使用 HTTP 升级来将 HTTP 协议换位 Web Socket 协议。
由于 Web Sockets 使用了自定义的协议,所以 URL 模式也略有不同。未加密的连接不再是http://,而是ws://;加密连接也不是https://,而是wss://
Web Sockets 的特点是,能够在客户端和服务端之间发送非常少量的数据,而不必担心 HTTP 那样的字节级开销。由于传递的数据包很小,因此 Web Sockets 非常适合移动应用。
同源策略对于 Web Sockets 不适用,只要服务器支持,它就可以进行跨域通信。

nginx 代理解决跨域

虽然浏览器对不同域的请求很敏感,但是通过配置 nginx 骗过浏览器,实现跨域。nginx 代理解决跨域的思路是,通过 nginx 解析 URL 地址的时候进行判断,将请求转发到具体的域上。
一个最简单的例子,将foo.example的请求转发到foo.example:3000
server
{
    listen 80;
    server_name foo.example;

    location / {
        proxy_pass <http://foo.example:3000>;
    }
}

参考资料:
  • 《JavaScript 高级程序设计》
 

© Sytone 2021 - 2024