再谈跨域那些事儿

当一个资源从与该资源本身所在的服务器不同的域或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。出于安全原因,浏览器限制从脚本内发起的跨域 HTTP 请求。

跨域不一定是浏览器限制了发起跨站请求,也可能是跨站请求可以正常发起,但是返回结果被浏览器拦截了。最好的例子是 CSRF 跨站攻击原理,请求是发送到了后端服务器无论是否跨域!注意:有些浏览器不允许从 HTTPS 的域跨域访问 HTTP,比如 Chrome 和 Firefox,这些浏览器在请求还未发出的时候就会拦截请求,这是一个特例。

跨域解决方案(我了解并且使用过的):

  1. jsonp
  2. CORS
  3. Node 中间件代理
  4. WebSocket 协议跨域

1. jsonp

  • 原理:利用 script 标签没有跨域限制的“漏洞”,来达到与第三方通讯的目的。在需要通讯时,本站脚本创建一个 script 标签,地址指向第三方的 API 网址,形如: <script src = "http://www.baidu.com/api?param1=aaa&param2=bbb"></script> , 并提供一个回调函数来接收数据(函数名可约定,或通过地址参数传递)。这样浏览器就会调用 callback 函数,并传递解析后 json 对象作为参数。本站脚本可在 callback 函数里处理传入的数据。

  • 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function jsonp(options) {
// 整理参数
options = options || {};
options.data = options.data || {};
options.timer = options.timer || 0;

// 随机回调函数的名字
var callbackName = 'jsonp' + Math.random();
callbackName = callbackName.replace('.', ' '); //函数名不可含有.
options.data[options.callback] = callbackName;

// 拼接url
var arr = [];
for(var key in options.data) {
arr.push(key + '=' + encodeURIComponent(options.data[key]));
}
options.url = options.url + '?' + arr.join('&');

// 添加script标签
var script = document.createElement("script");
script.type = "text/javascript";
script.src = options.url;
document.body.appendChild(script);

// 设定超时
if(options.timeout) {
var timer = setTimeout(function() {
document.body.removeChild(scipt);
window[options.data[options.callback]] = function() {
options.err && options.error();
}
}, options.timeout);
}

// 定义回调函数
window[options.data[options.callback]] = function(json) {
clearTimeout(timer);
options.success && options.success(json);
document.body.removeChild(script); //清除用过的script标签
window[options.data[options.callback]] = null; //释放window头上用过的属性
}
}
  • 缺点:

    1. jsonp 只能使用 GET 方法发起请求,这是由于 script 标签自身的限制决定的。
    2. jsonp 由于不是通过 XMLHttpRequest 进行传输,所以不能注册 success 和 error 等事件监听函数。

2. CORS(跨域资源共享)

  • 概述:跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

  • 简单请求:

    • 符合以下要求:

      1. HTTP Method 为:
        • GET
        • HEAD
        • POST
      2. HTTP Headers为(不可使用自定义请求头):
        • Accept
        • Accept-Language
        • Content-Language
        • Content-Type(值限于下列三者之一):
          • text/plain
          • multipart/form-data
          • application/x-www-form-urlencoded
      3. 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问
      4. 请求中没有使用 ReadableStream 对象
    • 简单请求的前端实现:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      let xhr = new XMLHttpRequest();
      var url = 'http://api.alice.com/cors';
      if(xhr) {
      xhr.open('GET', url, true);
      xhr.onreadystatechange = function() {
      if(xhr.readyState === 4 && xhr.status === 200) {
      console.log(xhr.responseText);
      }
      };
      xhr.send(null);
      }
    • 简单请求的 HTTP 请求报文:

      1
      2
      3
      4
      5
      6
      GET /cors HTTP/1.1
      Origin: http://api.bob.com
      Host: api.alice.com
      Accept-Language: en-US
      Connection: keep-alive
      User-Agent: Mozilla/5.0...

      值得注意的是 CORS 请求中必定包含 Origin 头部,但是包含此头部不一定意味着这个请求就是 CORS 请求。一个成功的跨域请求的响应报文可以是:

      1
      2
      3
      4
      Access-Control-Allow-Origin: http://api.bob.com
      Access-Control-Allow-Credentials: true
      Access-Control-Expose-Headers: FooBar
      Content-Type: text/html; charset=utf-8

      所有CORS相关的头部均以Access-Control-开头,下面是关于各个头部的细节:

      • Access-Control-Allow-Origin(required):此头部必须添加到响应报文中,不然缺省值会导致 CORS 请求失败。你可以设置 * 值让所有站点都可以访问你的数据,但最好还是控制一下
      • Access-Control-Allow-Credentials(optional):设置此头部的值为 true,如果你想要请求附带 cookies。与上文提到的 withCredentials 属性协作。若此头部值为 true 而 withCredentials 属性为 false,会导致请求失败,反之亦然
      • Access-Control-Allow-Expose-Headers(optional):XMLHttpRequest2 对象存在 getResponseHeader 方法,允许访问一些简单的响应头部如:Content-Type,Cache-Control 等等。如果想暴露一些特殊的头部,可以在此头部的值设置以逗号分隔的头部名称
  • 预检请求:“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

    • 符合以下要求:

      1. HTTP Method:
        • PUT
        • DELETE
        • CONNECT
        • OPTIONS
        • TRACE
        • PATCH
      2. HTTP Headers(人为设置了其他首部字段):
        • Accept
        • Accept-Language
        • Content-Language
        • Content-Type(值不属于下列之一):
          • application/x-www-form-urlencoded
          • multipart/form-data
          • text/plain
      3. 请求中的XMLHttpRequestUpload 对象注册了任意多个事件监听器
      4. 请求中使用了ReadableStream对象
    • 预检请求的前端实现:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      let xhr = new XMLHttpRequest();
      var url = 'http://api.alice.com/cors';
      if(xhr) {
      xhr.open('PUT', url, true);
      xhr.setRequestHeader('X-PINGPONG', 'pingpong');
      xhr.onreadystatechange = function() {
      if(xhr.readyState === 4 && xhr.status === 200) {
      console.log(xhr.responseText);
      }
      };
      xhr.send('hello');
      }
    • 预检请求的 HTTP 请求报文:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      // 预检请求:
      OPTIONS /cors HTTP/1.1
      Origin: http://api.bob.com
      Access-Control-Request-Method: PUT
      Access-Control-Request-Headers: X-Custom-Header
      Host: api.alice.com
      Accept-Language: en-US
      Connection: keep-alive
      User-Agent: Mozilla/5.0...
      • Access-Control-Request-Method:真实的请求方法,在此为PUT。所有的Preflight请求都应该包含此头部
      • Access-Control-Request-Headers:值是以逗号分隔的头部名称,代表请求附带的其余头部
    • 预检请求的 HTTP 响应报文:

      1
      2
      3
      4
      5
      6
      Access-Control-Allow-Origin: http://api.bob.com
      Access-Control-Allow-Methods: GET, PUT, OPTIONS
      Access-Control-Allow-Headers: X-Custom-Header
      Access-Control-Allow-Credentials: true
      Access-Control-Max-Age: 86400
      Content-Type: text/html; charset=utf-8
      • Access-Control-Allow-Headers:当请求中有Access-Control-Request-Headers 头部时,此响应头说明服务器支持的头部,以逗号分隔
      • Access-Control-Allow-Credentials: (同上文)
      • Access-Control-Max-Age(required):指定preflight 响应可以被缓存的时间,单位秒

      真实的请求跟响应就可以正常发送接收了。如果服务器对 preflight 请求直接返回 HTTP 200,不包含任何 CORS 指定的头部,那么这个跨域请求就会失败,触发 onerror 事件。控制台中会输出类似一下的报错信息:

      1
      XMLHttpRequest cannot load http://api.alice.com. Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

3. Node 中间件代理(Vue框架)

利用 node + webpack + webpack-dev-server 代理接口跨域。在开发环境下,由于 vue 渲染服务和接口代理服务都是 webpack-dev-server 同一个,所以页面与代理接口之间不再跨域,无须设置 headers 跨域信息了。

  • webpack.config.js 部分设置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
    historyApiFallback: true,
    proxy: {
    '/api/*': {
    target: 'https://****', // 代理跨域目标接口
    changeOrigin: true,
    secure: false, // 当代理某些 https 服务报错时用
    }
    }
    }
    }

4. WebSocket 协议跨域

WebSocket protocol 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是 server push 技术的一种很好的实现。

  • WebSocket 协议跨域的前端实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // html
    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport"
    content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Websocket</title>
    </head>
    <body>
    <div>user input:<input type="text"></div>

    <script src="./node_modules/socket.io-client/dist/socket.io.js"></script>
    <script>
    var socket = io('http://www.domain2.com:8080');

    // 连接成功处理
    socket.on('connect', function() {
    // 监听服务端消息
    socket.on('message', function(msg) {
    console.log('data from server: ---> ' + msg);
    });

    // 监听服务端关闭
    socket.on('disconnect', function() {
    console.log('Server socket has closed.');
    });
    });

    document.getElementsByTagName('input')[0].onblur = function() {
    socket.send(this.value);
    };
    </script>
    </body>
    </html>
  • Nodejs socket 后端实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    var http = require('http');
    var socket = require('socket.io');

    // 启http服务
    var server = http.createServer(function(req, res) {
    res.writeHead(200, {
    'Content-type': 'text/html'
    });
    res.end();
    });

    server.listen('8080');
    console.log('Server is running at port 8080...');

    // 监听socket连接
    socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(msg) {
    client.send('hello:' + msg);
    console.log('data from client: ---> ' + msg);
    });

    // 断开处理
    client.on('disconnect', function() {
    console.log('Client socket has closed.');
    });
    });