Skip to content

同源策略

同源策略(Same-Origin Policy),是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的很多功能都无法正常使用。 "同源" 指的是 "协议 + 域名 + 端口" 三者相同。 同源策略的主要目标是为了保护用户的信息安全,防止恶意的网站访问和窃取其他网站的数据。

当前页面url被请求页面url跨域原因
http://www.test.com/http://www.test.com/index.html同源(协议、域名、端口号相同)
http://www.test.com/https://www.test.com/index.html跨域协议不同(http/https)
http://www.test.com/http://www.baidu.com/跨域主域名不同(test/baidu)
http://www.test.com/http://blog.test.com/跨域子域名不同(www/blog)
http://www.test.com:8080/http://www.test.com:7001/跨域端口号不同(8080/7001)

那些行为受到跨域的限制?

  • Cookie、LocalStorage 和 IndexDB 不能进行跨域访问。(访问存储在浏览器中的数据,如 localStorage 和 IndexedDB,是以源进行分割。每个源都拥有自己单独的存储空间)
  • DOM 无法获取到其他页面的对象
  • AJAX 请求不能发送到不同源的地址,除非它支持 CORS(跨源资源共享)

Ajax为什么不能跨域访问

Ajax 其实就是向服务器发送一个 GET 或 POST 请求,然后取得服务器响应结果,返回客户端。


Ajax 跨域请求,在服务器端不会有任何问题。 在客户端,实际上,请求会正常发送到服务器,服务器也会正常返回响应。 只是服务端响应数据返回给浏览器的时候,浏览器根据响应头的Access-Control-Allow-Origin字段的值来判断是否有权限获取数据,服务端如果没有设置跨域字段设置,跨域请求就没有权限访问,数据被浏览器给拦截了。

跨域的解决方案

  1. 跨域资源共享CORS
  2. JSONP(只支持get)
  3. postMessage跨域
  4. nginx代理跨域
  5. nodejs中间件代理跨域

CORS

跨域资源共享CORS,是一种 W3C 标准,允许服务器在响应头中加入特殊字段,如 "Access-Control-Allow-Origin",来开放对指定来源的跨域请求。这是最标准也最通用的跨域解决方案。

CORS请求步骤

  • CORS 请求一般分为两种:简单请求和复杂请求
  • 当我们发起跨域请求时,如果是复杂请求,浏览器会帮我们自动触发预检请求,也就是 OPTIONS 请求,用于确认目标资源是否支持跨域。如果是简单请求,则不会触发预检,直接发出正常请求。
  • 浏览器会根据服务端响应的 header(Access-Control-Allow-origin) 进行判断,如果响应支持跨域,则继续发出正常请求,如果不支持,则在控制台显示错误。

简单请求

满足一下条件,则可视为简单请求,不会触发CORS预检请求

使用下列方法之一:

  • GET
  • HEAD
  • POST

除了被用户代理自动设置的请求头(connecttion,User-Agent)允许人为设置的字段只能是下面几种

image.png

Content-Type只能是下面三种

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

其他一些条件

简单请求的流程主要有以下两个步骤:

  1. 客户端直接发出CORS请求,就是正常的XMLHttpRequest请求,添加了Origin头信息。
  2. 服务器返回的响应,会多出几个头信息Access-Control-Allow-Origin,这表示服务器接受这次跨源请求。

复杂请求

凡是不同时满足上面两个条件,就属于非简单请求(复杂请求)。 复杂请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为 预检请求 ,该请求的方法是 Option,通过该请求来查询服务端是否允许跨域请求。

预检请求的流程主要有以下步骤:

  1. 首先,浏览器会向服务器发送一次预检请求,这是一种 OPTIONS 请求,其中包含了 CORS 相关请求头信息,比如 Origin(标明这个请求发出的域),Access-Control-Request-Method(用来让服务器判断实际发出请求将用什么方法,是否允许, 此标头必须)。
  2. 如果服务器允许这次请求,那么就会返回一个包含了 Access-Control-Allow-* 头的响应。这些头信息表明了服务器允许的源、方法、自定义请求头等信息。
    1. Access-Control-Allow-Origin: 能够被允许发出这个请求的域名,也可以使用*来表明允许所有域名;
    2. Access-Control-Allow-Methods: 用逗号分隔的被允许的请求方法的列表;
    3. Access-Control-Allow-Headers: 用逗号分隔的被允许的请求头部字段的列表;
    4. Access-Control-Max-Age: 这个预检请求能被缓存的最长时间,在缓存时间内,同一个请求不会再次发出预检请求。
  3. 浏览器判断预检响应是有效的(即服务器允许跨域请求)后,就会发出实际的 AJAX 请求。
  4. 服务器对这次请求再次进行响应,如果响应头部中的 Access-Control-Allow-Origin 允许源的请求,那么前端就可以接收到响应数据。

跨域请求如何携带Cookie

  • 服务器的Access-Control-Allow-Origin 设置为* 或者对应的域名
  • 服务器的响应头中**Access-Control-Allow-Credentials: true**
  • 浏览器SameSite要为none
  • Request 请求设置withCredentials为true(不是请求头设置)

聊聊withCredentials

withCredentials 不是在请求头中设置的,而是作为请求对象 XMLHttpRequest 或 fetch 的一个属性设置的 在跨域请求中,如果想要包含 Cookie 或者 HTTP 认证信息,需要设置 withCredentials 属性为 true


对于 XMLHttpRequest:

typescript
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'http://example.com', true);
  xhr.withCredentials = true; // 设置 withCredentials 属性
  xhr.send();

对于 fetch API:

typescript
  fetch('http://example.com', {
    credentials: 'include' // 这等同于设置 withCredentials = true
  });

设置这个属性后,跨域的 AJAX 请求将会带上源站点的数据(包括 Cookie 和 HTTP 认证信息)。

JSONP

JSONP 的原理很简单,就是利用 <script> 标签没有跨域限制的漏洞。通过 <script>标签指向一个需要访问的地址并提供一个回调函数来接收数据。

typescript
<script src="http://xxxxxx&callback=jsonp"></script>
<script>
    function jsonp(data) {
        console.log(data)
    }
</script>

postMessage跨域

window.postMessage() 方法可以安全地实现跨源通信。我们只需要拥有另一个窗口的引用,就可以传递消息给另一个窗口;

从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个 MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件(en-US)。传递给 window.postMessage() 的参数(比如 message)将通过消息事件对象暴露给接收消息的窗口

语法

otherWindow.postMessage(message, targetOrigin, [transfer])

otherWindow:其他窗口的一个引用,比如

  1. iframe 的 contentWindow 属性、
  2. 执行window.open返回的窗口对象、
  3. 或者是命名过或数值索引的window.frames (en-US)。

也就是说,postMessage可以解决以下访问的问题

  • 页面和页面上打开的新窗口的数据传递(使用window.open打开的)
  • 页面与嵌套的 iframe 消息传递

举例

我们假设有两个页面:父页面 Parent.html 和在父页面中的一个<iframe>子页面 Child.html

  1. 引用子页面 Child.html 的父页面 Parent.html
html
<!DOCTYPE html>
<html>
  <body>
    <iframe id="iframe" src="https://child.com/Child.html" style="display:none;"></iframe>
    <button onClick="postMessageToIframe()">Post Message to Iframe</button>
    <script>
      function postMessageToIframe() {
        const iframe = document.getElementById('iframe');
        iframe.contentWindow.postMessage('Hello Child', 'https://child.com');
      }
    </script>
  </body>
</html>

在上述代码中,父页面通过 postMessage 方法向子页面发送了消息 Hello Child

  1. 子页面Child.html位于另一个域:
html
<!DOCTYPE html>
<html>
  <body>
    <script>
      window.addEventListener('message', function(event) {
        if (event.origin !== 'http://parent.com') return;
        console.log('Received message:', event.data);
        event.source.postMessage('Hello Parent', event.origin);
      }, false);
    </script>
  </body>
</html>

子页面侦听 message 事件以接收父页面发送过来的消息,然后检查发送消息的源是否与期望的源一致,如果一致,则处理这条消息,并向发送消息的窗口回发一条消息 Hello Parent

Nignx Proxy方案

在实际的开发中,通过 Nginx 代理的方式是非常常见的解决跨域的方案之一。 原理是,由于浏览器执行 Javascript 的安全策略仅放行同源(即相同的域名、端口,协议)的请求,所以我们可以在前端服务器上设置一个代理,将请求先发给自家的服务器,再由服务器去请求其他源的数据,然后返回给浏览器,这样浏览器的同源策略就会认为没有问题了。

nginx
server {
    listen 80;
    server_name mydomain.com;
   
    location /api/ {
        proxy_pass http://otherdomain.com; # 另一个域名
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

在上面配置里,所有前端发送至 'mydomain.com/api' 的请求都会被 Nginx 服务器转发给 'otherdomain.com'。

  • 请求头的 'Host' 被重写为当前 Nginx 服务器的地址,
  • 而 'X-Real-IP' 和 'X-Forwarded-For' 则会被添加到请求头中,方便调试跟踪源的 IP 地址

返回给前端时,服务器也会对响应做出一些修改,前端得到的仍然是一个看起来像是从 Nginx 代理服务器上返回的结果,而不是直接从其他源获取的结果,因此不会产生跨域问题。


再比如这样一个配置

nginx
server {
  listen  80;
  server_name  client.com;
  location /api {
    proxy_pass server.com;
  }
}

Nginx 相当于起了一个中转站,这个中转站的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。

跨页签通信 & 多tab通信

localStorage/sessionStorage

一个页面修改了 LocalStorage/SessionStorage,其他页签可以通过监听'storage'事件获得新的数据。但需要注意的是,这种方式只有在页签之间共享相同源(同域名)的情况下才可以使用。

  • 重复写入相同的值无法触发
  • 会受到浏览器隐身模式等的限制
typescript
// 页面 A
localStorage.setItem('myKey', 'myValue');
// 页面 B
window.addEventListener('storage', function(event) {
  if (event.key == 'myKey') {
    console.log(event.newValue);
  }
});

postMessage

  • 通过父页面window.open()和子页面postMessage

虽然postMessage一般用于iframe和宿主页面之间的通信,但也可以用于其他窗口或页签。

typescript
// 页面 A
const win = window.open('http://example.com/pageB.html');
win.postMessage('Hello!', 'http://example.com');

// 页面 B
window.addEventListener('message', function(event) {
  if (event.origin !== 'http://example.com') return;
  console.log(event.data);
}, false);

BroadcastChannel

是一个比较新的API,它提供了一个简单的方法,可以在同源的不同页面(包括iframe)之间进行通信。

typescript
// 页面 A
const bc = new BroadcastChannel('test_channel');
bc.postMessage('This is a message from page A.');

// 页面 B
const bc = new BroadcastChannel('test_channel');
bc.onmessage = function (event) { console.log(event.data); };

SharedWorker

SharedWorker 是 Web Workers 的一种,可以同一源下的多个页面共享。 SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域.

如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。

typescript
// 页面 A
const worker = new SharedWorker('worker.js');
worker.port.postMessage('This is page A.');

// 页面 B
const worker = new SharedWorker('worker.js');
worker.port.onmessage = function(event) {
  console.log(event.data);
};

WebSocket配合服务器

WebSocket可以配合服务器实现多个浏览器tab之间的通信。 当用户开启一个新的tab并建立WebSocket连接时,服务器可以通过唯一的用户ID或session来识别这个用户。 当服务器接收到特定用户推送的消息时,可以将消息广播到所有该用户ID的活动连接。 这样,当用户在一个tab页面中进行操作,其他所有tab页面都可以接收到这个操作的通知。 客户端:

typescript
// 创建一个WebSocket连接
const socket = new WebSocket('ws://example.com/socketserver');
socket.addEventListener('message', function (event) {
    console.log('收到消息: ', event.data);
});
// 在需要的时候,向服务器发送消息
socket.send('你好,我是客户端');

服务器端

typescript
const WebSocket = require('ws');

// 初始化Websocket服务器
const wss = new WebSocket.Server({ port: 8080 });

let connections = {};

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    // 假设消息的结构如下:{userId:"xxx", content:"xxx"}
    let msg = JSON.parse(message);

    if(!connections[msg.userId]) connections[msg.userId] = [];  // 如果不存在该用户的连接数组,初始化一个
    connections[msg.userId].push(ws);  // 将新ws连接添加到该用户的连接数组

    // 广播该用户所有的连接
    connections[msg.userId].forEach(conn => conn.send(msg.content));
  });
});