01 跨域

0.1. 跨域

跨域,是指浏览器不能执行其它网站的脚本。它是由浏览器的同源(所谓同源,就是协议、域名、端口均相同)策略造成的,是浏览器对Javascript实施的安全限制。

比如,站点 http://domain-a.com 的某 HTML 页面通过 <img> 的 src 请求 http://domain-b.com/image.jpg,这就发起了一个跨域HTTP请求。

0.2. CORS

随着互联网的发展,同源策略严重影响了项目之间的连接,尤其是大项目,需要多个域名配合完成,因此W3C推出了CORS(Cross-origin resource sharing,跨域资源共享)。

CORS的基本思想就是使用额外的HTTP头部让浏览器与服务器进行沟通,从而决定是否接受跨域请求。跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器,让运行在一个 origin (domain) 上的Web应用被准许访问来自不同源服务器上的指定的资源。

网络上的许多页面都会加载来自不同域的CSS样式表,图像和脚本等资源。出于安全原因:

  1. 浏览器限制从脚本内发起的跨源HTTP请求
  2. 或者跨站请求可以正常发起,但是返回结果被浏览器拦截

例如,XMLHttpRequestFetch API遵循同源策略。这意味着使用这些API的Web应用程序只能从加载应用程序的同一个域请求HTTP资源,除非响应报文包含了正确CORS响应头。

跨域资源共享( CORS )机制允许 Web 应用服务器进行跨域访问控制,从而使跨域数据传输得以安全进行。现代浏览器支持在 API 容器中(例如 XMLHttpRequestFetch )使用 CORS,以降低跨域 HTTP 请求所带来的风险。

CORS请求失败会产生错误,但是为了安全,在JavaScript代码层面是无法获知到底具体是哪里出了问题,只能查看浏览器的控制台以得知具体是哪里出现了错误。

浏览器在跨域访问时,会自动添加HTTP头信息,或者发起预检请求,用户对此毫无感知。因此是否支持跨域请求,关键在于服务器是否做了CORS配置,允许跨域访问。

浏览器将跨域请求分为两类:简单请求和非简单请求(预检请求)。对于这两种请求,浏览器的处理方式是不一样的。

0.2.1. 简单请求

简单请求的请求方法只能是:

  • GET
  • POST
  • HEAD

简单请求的请求头只能是:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type的值只能是:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width

简单请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

简单请求中没有使用 ReadableStream 对象。

对于简单请求,浏览器采用先请求后判断的方式,即浏览器直接发出CORS请求,在请求头中增加Origin字段来向服务器说明,本次请求来自于哪个源(协议+域名+端口),服务器决定是否允许这个源的访问。

0.2.1.1. Origin不在服务器允许的范围内

  1. 返回一个正常的HTTP响应
  2. 浏览器判断响应头中是否包含Access-Control-Allow-Origin字段,如果没有,浏览器就知道服务器是不允许跨域访问的,就会抛出错误

0.2.1.2. Origin在服务器允许的范围内

  1. 服务器的HTTP响应中,就会包含如下字段:
字段名字段值描述
Access-Control-Allow-Credentialstrue布尔值,表示是否允许发送Cookie,默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器
Access-Control-Allow-HeadersContent-Type、Content-Length、Authorization、Accept、X-Request-With、Cookie允许浏览器在CORS中发送的头信息
Access-Control-Allow-MethodsGET、PUT、POST、DELETE、OPTIONS允许浏览器在CORS中使用的方法
Access-Control-Allow-Origin请求头中Origin字段的值或者**表示接受任意域名的请求,如果服务端指定了具体的域名而非*,那么响应头中的Vary字段的值必须包含Origin。这将告诉客户端:服务器对不同的源站返回不同的内容
  1. 浏览器收到服务器返回的HTTP响应后,即可知道什么样的CORS请求是被允许的。

0.2.2. 非简单请求

对于非简单请求,浏览器采用预检请求,询问服务器是否支持跨域请求。

  1. 在正式的请求之前,浏览器会预先发送一个额外的OPTIONS请求,询问服务器当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP方法和头字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

在这个OPTIONS请求中,包含:

  • Origin
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Methods

用于询问服务器支持跨域访问的请求头和请求方法。

  1. 服务器在收到OPTIONS预检请求后,检查上面三个字段,并作出响应。
字段名字段值描述
Access-Control-Allow-Credentialstrue布尔值,表示是否允许发送Cookie,默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器
Access-Control-Allow-HeadersContent-Type、Content-Length、Authorization、Accept、X-Request-With、Cookie允许浏览器在CORS中发送的头信息
Access-Control-Allow-MethodsGET、PUT、POST、DELETE、OPTIONS允许浏览器在CORS中使用的方法
Access-Control-Allow-Origin请求头中Origin字段的值或者**表示接受任意域名的请求,如果服务端指定了具体的域名而非*,那么响应头中的Vary字段的值必须包含Origin。这将告诉客户端:服务器对不同的源站返回不同的内容
Access-Control-Max-Age3600用来指定本次预检请求的有效期,单位为秒。如有效期是3600秒,即允许缓存该条回应3600秒,在此期间,可直接发送正式请求,不用再发预检请求

0.3. 跨域中的Cookie

cookie 属性:

  • path
  • domain
  • expire
  • HttpOnly
  • Secure
  • SameSite,一种新的防止跨站点请求伪造(CSRF)的HTTP安全特性

Chrome 80 默认将没有设置SameSite设置为SameSite=Lax

SameSite的值对应的描述
Strict最为严格,完全禁止第三方Cookie,跨站点时,任何情况下都不会发送Cookie
Lax稍稍放宽,大多数情况也是不发送第三方Cookie,但是导航到目标网址的 Get 请求除外,见下面表格
None网站可以选择显式关闭SameSite属性,将其设为None。前提是必须同时设置Secure属性(Cookie 只能通过 HTTPS 协议发送),否则无效

0.3.1. 导航到目标网站的Get请求

导航到目标网址的 GET 请求,只包括三种情况:链接,预加载请求,GET 表单。详见下表。

请求类型示例正常情况Lax
链接<a href="..."></a>发送 Cookie发送 Cookie
预加载<link rel="prerender" href="..."/>发送 Cookie发送 Cookie
GET 表单<form method="GET" action="...">发送 Cookie发送 Cookie
POST 表单<form method="POST" action="...">发送 Cookie不发送
iframe<iframe src="..."></iframe>发送 Cookie不发送
AJAX$.get("...")发送 Cookie不发送
Image<img src="...">发送 Cookie不发送

在用户浏览器支持 SameSite 属性的情况下,设置了StrictLax以后,基本就杜绝了CSRF 攻击。

0.4. 跨域的解决方案

遇到跨域的报错,可以分别从客户端和服务端去解决。

0.4.1. 客户端

跨域的判断是在浏览器进行的,服务器只是根据客户端的请求做出正常的响应,服务端不对跨域做任何判断。因此如果禁用了浏览器的跨域检查,使浏览器不再对比Origin是否被服务器允许,即可发出正常的请求。

Chrome 80 版本开始,跨站访问时直接不携带cookie进行请求的发送,其他的浏览器可以正常访问。因此,定位到和浏览器版本有关,新增了一个cookie的属性,samesite

0.4.1.1. 修改启动参数

需要所有客户都修改浏览器的设置,因此只在开发调试的过程中使用,如:给Chrome浏览器设置--disable-web-security启动参数。

0.4.1.2. 修改浏览器设置

  1. 谷歌浏览器地址栏输入:chrome://flags/
  2. 找到:SameSite by default cookiesCookies without SameSite must be secure
  3. 设置上面这两项设置成 Disable

0.4.1.3. 修改本地hosts文件

127.0.0.1.1:8888 api.test.com // 把本地请求转向到api接口

0.4.2. 服务端

0.4.2.1. 代理服务

增加代理服务器,和发起跨域请求的服务器放在同一个域名下,接口请求全走代理服务器,这样就变成了同源访问,不存在跨域访问,因此就不会存在跨域的问题。该方式中,所有发往目标服务器的数据,都会经过代理服务器,适用于同一个公司内部不同域名之间相互访问的情况。

使用此方式需注意代理服务器的性能,应与后端的目标服务器的性能相匹配,否则代理服务器会成为整个系统的性能瓶颈。

0.4.2.1.1. 正向代理
// webpack
  devServer: {
    port: 8000,
    proxy: {
      "/api": {
        target: "http://localhost:8080"
      }
    }
  }

// Vue-cli 2.x
proxyTable: {
  '/api': {
     target: 'http://localhost:8080',
  }
}

// Vue-cli 3.x vue.config.js
devServer: {
    port: 8000,
    proxy: {
      "/api": {
        target: "http://localhost:8080"
      }
    }
  }


// Parcel 2.x   .proxyrc
{
  "/api": {
    "target": "http://localhost:8080"
  }
}
0.4.2.1.2. 反向代理
# Nginx
server {
        listen 80;
        server_name local.test;
        location /api {
            proxy_pass http://localhost:8080;
            proxy_cookie_domain : localhost:8080 local.test; //当reseponse的set-cookie中设置domain时才需要配置此项, 用于修改set-cookie的domain指向
        }
        location / {
            proxy_pass http://localhost:8000;
        }
}

0.4.2.2. 配置CORS

在目标服务器上配置CORS响应头,这样浏览器经过对比判断之后,就可以发起正常的访问。

add_header Access-Control-Allow-Origin *
add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS
add_header Access-Control-Allow-Headers *

此方式的优点是不用修改应用代码,缺点是不能做细粒度的编程,从而做到细粒度的控制,如根据请求参数的不同而返回不同的结果。

0.4.2.3. 修改应用代码

由于是通过代码控制,因此可以实现细粒度的控制,在解决跨域问题的同时,可以满足复杂的业务需求。

app.use(async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
  ctx.set("Access-Control-Allow-Credentials", true);
  ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
  ctx.set(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept"
  );
  if (ctx.method === "OPTIONS") {
    ctx.status = 204;
    return;
  }
  await next();
});

// 或者使用现成的库

const cors = require("koa-cors");
app.use(cors());
0.4.2.3.1. JSONP

JSONP(JSON with Padding)是JSON的一种“使用模式”, 利用 script 标签没有跨域限制的特性, 解决跨域问题。

  • 兼容性较好
  • 只能发GET请求
<!-- HTML-->
 <script src="http://127.0.0.1:3000/list?callback=func"></script>
 <script>
 function func(res){
  //处理res
 }
 </script>
// javascript
 let express = require('express'),
 app = express()
app.listen(8888, _=>{
    console.log('ok')
})
app.get('/list', (req, res)=>{
    let { callback = function(){} } = req.query;
    let data = {
        code: 0,
        message: '返回数据'
    }
    res.send(`${callback}(${JSON.stringify(data)})`)
})
0.4.2.3.2. window.postMessage

window.postMessage()方法可以安全地实现跨源通信。

应用场景:

  1. 页面和其打开的新窗口的数据传递
  2. 多窗口之间消息传递
  3. 页面与嵌套的 iframe 消息传递
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow: 其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames。
  • message: 将要发送到其他 window 的数据。
  • targetOrigin: 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件.
  • transfer(可选) : 是一串和 message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权
<!--  a页面 -->
<iframe01-CORS和Cookie
  src="http://localhost:8080"
  frameborder="0"
  id="iframe"
  onload="load()"
></iframe>
<script>
  function load() { //消息传递需要写在onload事件中
    iframe.contentWindow.postMessage("消息", "http://localhost:8080"); //往b发送消息
    window.onmessage = e => {
      console.log(e.data); //接收b返回的消息
    };
  }
</script>
<!-- b页面 -->
<div>hello</div>
<script>
  window.onmessage = e => { //监听来自a的消息
    console.log(e.data);
    e.source.postMessage('返回消息:' + e.data, e.origin); //往a返回消息
  };
</script>

不同源的网站依旧可以通过iframe方式引入到html, 但需要一些额外处理才能获取想要传递的信息, 这就出现了下面三种基于iframe的跨域方式。

0.4.2.3.3. document.domain + Iframe

只能适用于二级域名相同的情况下,比如 a.test.comb.test.com , 只需要给两个页面同时添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域。

<!-- a.test.com -->
<body>
  <iframe
    src="http://b.test.com/b.html"
    frameborder="0"
    onload="load()"
    id="frame"
  ></iframe>
  <script>
    document.domain = "test.com"; //设置主域
    function load() {
      console.log(frame.contentWindow.qqq);
    }
    var aaa = 999;
  </script>
</body>
<!-- b.test.com -->
<body>
  hellob
  <script>
    document.domain = "test.com";//设置主域
    var qqq = 100;
     console.log(window.parent.aaa)
  </script>
</body>
0.4.2.3.4. window.location.hash + Iframe

原理就是通过 url 带 hash ,通过一个非跨域的中间页面来传递数据,但是hash是在地址栏中的, 能传递的数据有限, 例如chrome < 8k, ie < 2k。

<!-- a.html 和 b.html 是同源的,都是http://localhost:8000,而 c.html 是http://localhost:8080 -->
<!-- a.html -->
<iframe src="http://localhost:8080/hash/c.html#name1"></iframe>
<script>
  console.log(location.hash);
  window.onhashchange = function() {
    console.log(location.hash);
  };
</script>
<!-- b.html -->
<script>
  window.parent.parent.location.hash = location.hash;
</script>
<!-- c.html -->
<body></body>
<script>
  console.log(location.hash);
  const iframe = document.createElement("iframe");
  iframe.src = "http://localhost:8000/hash/b.html#name2";
  document.body.appendChild(iframe);
</script>
0.4.2.3.5. window.name + Iframe

window 对象的 name 属性是一个很特别的属性,当该 window 的 location 变化,然后重新加载,它的 name 属性可以依然保持不变。

a跨域访问c, 之后再指向同源的b (注意,这个重新指向在onload事件中只要执行一次, 否则会造成无限循环), 只要b中没有修改window.name, window就会保持 c 页面中的值。

<!-- a.html 和 b.html 是同源的,都是http://localhost:8000,而 c.html 是http://localhost:8080 -->
<!-- a.html -->
<iframe
  src="http://localhost:8080/name/c.html"
  frameborder="0"
  onload="load()"
  id="iframe"
></iframe>
<script>
  let first = true;
  // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
  function load() {
    if (first) {
      // 第1次onload(跨域页)成功后,切换到同域代理页面
      //此处转向只要执行一次,否则相当于一直加载iframe,一直调用onload事件, 这样就是死循环
      iframe.src = "http://localhost:8000/name/b.html";
      first = false;
    } else {
      // 第2次onload(同域b.html页)成功后,读取同域window.name中数据
      console.log(iframe.contentWindow.name);
    }
  }
</script>
<!-- b.html -->
<div></div>
<!-- c.html -->
<script>
  window.name = "ccc";
</script>

可以在 http 返回头 添加X-Frame-Options: SAMEORIGIN 防止被别人添加至 iframe。

0.4.2.4. 升级协议

使用Websocket而不使用HTTP, 就不会有跨域的限制。

<script>
  let socket = new WebSocket("ws://localhost:8080");
  socket.onopen = function() {
    socket.send("消息");
  };
  socket.onmessage = function(e) {
    console.log(e.data);
  };
</script>
const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 8080 });
server.on("connection", function(socket) {
  socket.on("message", function(data) {
    socket.send(data);
  });
});
上次修改: 14 April 2020