《跨域:从入门到深入》

795 阅读11分钟

前言

日常处理前后端数据交互的过程中,跨域问题并不陌生,相信大部分人看到跨域问题就能立马想到它的解决办法,但你真的了解它的实现原理吗?尝试回答下面的问题:

  1. 为什么会存在跨域问题?
  2. 跨域是谁的机制?
  3. 同源策略是什么?它限制了什么?
  4. 为什么浏览器会使用同源策略?
  5. JSONP可以利用哪些标签绕开限制?
  6. 跨域请求如何携带cookie?
  7. options预检请求什么时候触发?它是否会真正执行请求?它的作用是什么?
  8. 浏览器会在什么时机拦截?请求能发出去吗?
  9. 你知道哪些常用的解决跨域的方法?
  10. 你能说出解决跨域的各个方法实现原理吗?

...

跨域4个W1个H

一、what:什么是跨域?

  • 广义的跨域:一个域下的文档或脚本试图去请求另一个域下的资源。
  • 狭义的跨域:由浏览器同源策略限制的一类请求场景。

二、when:什么时候会发生跨域?

截屏2022-09-04 上午11.19.46.png 不同域(协议、子域名、主域名、端口号中任意一个不相同)之间相互请求资源的时候就发生了。

三、why:为什么会存在跨域的问题?

1、什么是同源策略?

同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSRF 等攻击。

2、为什么浏览器会使用同源策略?

设置同源策略是为了保证用户信息的安全,防止恶意的网站窃取数据。如果没有同源限制,在浏览器中的cookie等其他数据可以任意读取,不同域下的DOM可以任意操作,任意请求其他网站的数据,包括隐私数据。后果将无法想象...

(1995年,同源策略由 Netscape 公司引入浏览器。目前所有浏览器都实行这个政策。浏览器同时还规定,提交表单不受同源同源策略的限制

3、同源策略限制了什么?

  • Cookie、LocalStorage、IndexedDB 等存储性内容无法获取
  • DOM 节点无法获取
  • AJAX 请求不能发送

四、where: 常见的跨域场景?

协议/域名/端口 只要有其一不同就是跨域;即便是同一个ip地址对应的两个不同域名之间也是跨域(因为它仅通过 URL的首部 来识别,而不是根据域名对应的IP地址是否相同来判断)。

http端口号80,https端口号443

截屏2022-09-04 上午11.04.43.png

五、how: 怎样解决跨域问题?

1、jsonp

通常为了减轻web服务器的负载,我们把js、css,img等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许,基于此原理,我们可以通过动态创建script,再请求一个带参网址实现跨域通信。

<img src=XXX>
<link href=XXX>
<script src=XXX>
<iframe src=XXX>

实现原理:利用带有 src 属性的以上标签都不受同源策略的限制的漏洞,因而绕过了限制。

基本思想:网页通过添加一个<script>元素,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。

缺点:需要前后端配合,且src中只能是get请求、不安全可能会遭受XSS攻击

那为什么同源策略不限制这些标签呢 因为通过标签引入的代码,不会被js代码更改,因而认定它是安全的,故不会对其限制。

实践:

搭建node server

const http = require('http');
const url = require('url'); //引入url模块解析url字符串
const querystring = require('querystring'); //引入querystring模块处理query字符串
const server = http.createServer(); //创建新的HTTP服务器
const resData = {
  name: 'june',
  password: '123456'
};

//通过request事件来响应request请求
server.on('request', function (req, res) {
  var urlPath = url.parse(req.url).pathname;
  var qs = querystring.parse(req.url.split('?')[1]);
  if (urlPath === '/jsonp' && qs.callback) {
    res.writeHead(200, { 'Content-Type': 'application/json;charset=utf-8' });
    var callback = qs.callback + '(' + JSON.stringify(resData) + ');';
    res.end(callback);
  } else {
    res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
    res.end('Hell World\n');
  }
});
// 配置监听端口号及成功运行提示
server.listen(3000, () => {
  console.log('The server is running at http://localhost:3000');
});

JSONP将访问跨域请求变成了执行远程JS代码,服务端不再返回JSON格式的数据,而是返回了一段将JSON数据作为传入参数的函数执行代码。

我们先来尝试使用ajax请求

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>JSONP</title>
  </head>
  <body>
    <script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
    <script type="text/javascript">
      $(document).ready(function () {
        $.ajax({
          type: 'get',
          async: false,
          url: 'http://localhost:3000/jsonp?callback=jsonpCallback',
          type: 'json',
          success: function (data) {
            jsonhandle(data);
          },
        });
      });
    </script>
  </body>
</html>

截屏2022-09-04 下午5.22.31.png

利用jsonp的基本思想,通过添加一个<script>元素,向服务器请求JSON数据

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>JSONP解决跨域问题</title>
  </head>
  <body>
    <script>
      function addScriptTag(src) {
        var script = document.createElement('script');
        script.setAttribute('type', 'text/javascript');
        script.src = src;
        document.body.appendChild(script);
      }
      window.onload = function () {
        addScriptTag('http://localhost:3000/jsonp?callback=jsonpCallback');
      };
      // 回调的方法,且必须为全局方法,不然会报错
      function jsonpCallback(data) {
        console.log(data);
      }
    </script>
  </body>
</html>

截屏2022-09-04 下午5.29.17.png

2、cros

CORS(Cross-origin resource sharing 跨域资源共享),是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),使得浏览器允许这些 origin 访问加载自己的资源。

它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。

2.1: 那为什么设置cors接口之后就不会激活同源策略呢?因为js并不能更改request header,一旦它可以被更改那么这种方式也将失效。

2.2: 一个请求跨域了,这个请求是否已经发出去了?

1、首先我们需要明确跨域是谁的策略?浏览器

2、在什么时机会拦截请求?浏览器的策略和服务器并没关系,服务器不会对跨域请求做拦截,即使服务器想要拦截也没办法判断是否跨域,http request的Header是可以被篡改的。那是否浏览器会直接拦截不让请求发出去呢?我们知道浏览器会根据服务器返回的header去判断请求是否允许跨域,如果没有发出去请求,这个header信息从哪里来呢?浏览器又怎会知道 Server 允许请求在哪些 Origin 下跨域发送?所以请求是发出去的,只不过返回数据会被浏览器给拦截掉。

3、请求是否会真正执行?我们先来区分一下简单请求和复杂请求:

简单请求(simple request) 需要满足下面的条件:

  • 使用下列方法之一

  • HTTP的头信息不超出以下几种字段:

    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type 的值仅限于下列三者之一:text/plainmultipart/form-dataapplication/x-www-form-urlencoded
  • 请求中的任意 XMLHttpRequest 对象均没有注册任何事件监听器;XMLHttpRequest 对象可以使用 XMLHttpRequest.upload 属性访问。

  • 请求中没有使用 ReadableStream 对象

这是为了兼容表单(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。

复杂请求(not-so-simple request):不满足上述条件的就是复杂请求

对于复杂请求,浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request,目的:避免跨域请求对服务器的用户数据产生未预期的影响),从而获知服务端是否允许该跨源请求、支持哪些 HTTP 方法。服务器确认允许之后,才发起实际的 HTTP 请求(因此在network中会看到两次接口请求)。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证。

服务器收到"预检"请求以后,检查 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers 字段来确认是否允许跨源请求;若服务器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误。

2.3: 跨域如何携带cookie?

CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials:true,另一方面开发者必须在AJAX请求中设置 withCredentials:true。否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。但,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials:false

注:  

  • Origin 用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求
  • Access-Control-Allow-Origin: <origin> | * 指定了允许访问该资源的外域 URI
  • Access-Control-Allow-Credentials: true 来表明可以携带凭据进行实际的请求
  • 当响应的是 附带身份凭证的请求 时,服务端 必须 明确 Access-Control-Allow-Origin 的值,而不能使用通配符 “*
  • 实际的 POST 请求不会携带 Access-Control-Request-* 首部,它们仅用于 OPTIONS 请求。

实践:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>cros解决跨域问题</title>
  </head>
  <body>
    <script>
      let xhr = new XMLHttpRequest();
      document.cookie = 'name=june';
      xhr.withCredentials = true; // 前端设置是否允许携带cookie
      xhr.open('PUT', 'http://localhost:4000/getData', true);
      xhr.setRequestHeader('name', 'june');
      xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
          if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
            console.log(xhr.getResponseHeader('name'));
          }
        }
      };
      xhr.send();
    </script>
  </body>
</html>

local.js

const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(__dirname)); // 同层级的文件可以直接

app.listen(3000, () => {
  console.log('cros server is running at http://localhost:3000');
});

origin.js

let express = require('express');
let app = express();
let whitList = ['http://localhost:3000']; //设置白名单
app.use(function (req, res, next) {
  let origin = req.headers.origin;
  if (whitList.includes(origin)) {
    // 设置哪个源可以访问:内部接口可以直接设置为*,外部接口一般正常返回允许跨域的域名
    res.setHeader('Access-Control-Allow-Origin', origin);
    // 允许哪个方法访问:内部接口一般也是直接设置为*,外部接口会返回允许的所有方法,逗号分割返回
    res.setHeader('Access-Control-Allow-Methods', 'PUT');
    // 允许携带哪个头访问
    res.setHeader('Access-Control-Allow-Headers', 'name');
    // 允许携带cookie
    res.setHeader('Access-Control-Allow-Credentials', true);
    // 预检的存活时间
    res.setHeader('Access-Control-Max-Age', 6);
    // 允许返回的头
    res.setHeader('Access-Control-Expose-Headers', 'name');
    if (req.method === 'OPTIONS') {
      res.end(); // OPTIONS请求不做任何处理
    }
  }
  next();
});
app.put('/getData', function (req, res) {
  console.log(req.headers);
  res.setHeader('name', 'returnHeader'); //返回一个响应头,后台需设置
  res.end('put is ok');
});
app.get('/getData', function (req, res) {
  console.log(req.headers);
  res.end('get is ok');
});
app.use(express.static(__dirname));
app.listen(4000, () => {
  console.log('cros server is running at http://localhost:4000');
});

node运行local.js和serve.js,在端口号3000运行html,实现与端口号4000之间进行通信。

3、nginx反向代理

使用nginx反向代理实现跨域,是最简单的跨域方式。

基本原理:我们已经知道同源策略是浏览器的安全策略。服务器端调用HTTP接口只是使用HTTP协议,故不存在跨域的问题

实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。

nginx.conf配置如下,通过命令行nginx -s reload启动nginx

server {
    listen     81;
    server_name  www.domain1.com;

    location / {
        proxy_pass   http://www.domain2.com:8080;  #反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;

        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.domain1.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}

前端代码:

var xhr = new XMLHttpRequest();

// 浏览器是否读写cookie
xhr.withCredentials = true;

// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();

node后台:

var http = require('http');
var server = http.createServer();
var qs = require('querystring');

server.on('request', function(req, res) {
    var params = qs.parse(req.url.substring(2));

    // 向前台写cookie
    res.writeHead(200, {
        'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'   // HttpOnly:脚本无法读取
    });

    res.write(JSON.stringify(params));
    res.end();
});

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

4、node中间件代理

实现原理和nginx类似,它是通过设置cookieDomainRewrite参数修改响应头中cookie中的域名,来实现当前域的cookie写入。

利用node + express + http-proxy-middleware搭建一个proxy服务器(前端代码及node后台代码与上面一致)中间件服务器实现:

const express = require('express');
const proxy = require('http-proxy-middleware');
const app = express();
app.use('/', proxy({
    // 代理跨域目标
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,
    
    // 修改响应头信息,实现跨域并允许携带cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
        res.header('Access-Control-Allow-Credentials', 'true');
    },

    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
}));

app.listen(3000, () => {
    console.log('Proxy server is listen at port 3000...');
});

5、postMessage

postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递

语法:otherWindow.postMessage(message, targetOrigin, [transfer]);

a.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>postMessage-a</title>
  </head>
  <body>
    <iframe src="http://localhost:3000/b.html" frameborder="0" id="frame" onload="load()"></iframe>
    <script>
      function load() {
        const frame = document.getElementById('frame');
        frame.contentWindow.postMessage('send message', 'http://localhost:3000'); //发送数据
        window.onmessage = function (e) {
          // 接收传出的数据
          console.log(e.data); // postBack
        };
      }
    </script>
  </body>
</html>

b.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>postMessage-b</title>
  </head>
  <body>
    <script>
      window.addEventListener('message', receiveMessage, false);

      function receiveMessage(event) {
        console.log(event.data); // send message
        var origin = event.origin || event.originalEvent.origin;
        if (origin !== 'http://localhost:4000') return;
        event.source.postMessage('postBack', event.origin);
      }
    </script>
  </body>
</html>

将a.html运行在本地端口号4000,b.html运行在本地端口号3000,运行结果如下

截屏2022-09-12 下午4.44.26.png

6、websocket

背景:一般地,为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔,由浏览器对服务器发出HTTP请求,然后服务器返回最新的数据给客户端的浏览器。这种传统的模式存在以下缺点:

  • 推送延迟
  • 服务端压力:每次数据交换除了真正的数据部分外,服务器和客户端还要大量交换HTTP header
  • 推送延迟和服务端压力无法中和。减小轮询间隔,延迟降低,压力增加;反之,增加轮询的间隔,压力降低,延迟增高

WebSocket是一种在单个 TCP 连接上进行 全双工 通信的 持久化 协议。

WebSocket在建立握手时,数据是通过HTTP传输的。但建立之后,借助于TCP传输信道进行全双工通信,使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。

创建 长链接 ,实时性优势明显。

image.png

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>webSocket</title>
  </head>
  <body>
    <div>
      input:<input type="text" />
    </div>
    <script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
    <script>
      var socket = io('http://localhost: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>
var http = require('http');
var socket = require('socket.io'); // socket.io@2.0.4

// 启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.');
  });
});

截屏2022-09-12 下午5.45.59.png

小结

给耐心看完这篇文章的你点赞,相信到这里,开头提到的问题你都已经有了答案。

我们会发现,大部分解决跨域的办法都是 ‘绕过去‘ 的机制。

日常工作中我们使用较多的是cros和nginx代理;在使用canvas的时候也会经常遇到画布被污染等提示报错,熟悉跨域的原理之后我们都能轻松应对。欢迎大家一起探讨,共同进步!

参考文章