浏览器的同源策略(Same-origin policy)是一个重要的基础安全策略,它用于限制一个源(origin)的文档或者它加载的脚本如何能与另一个源的资源进行交互。如果从 http://example.com/doc.html 检索到的文档尝试访问从 https://example.com/target.html 检索到的文档的 DOM ,则用户代理将不允许访问。它能帮助阻隔恶意文档,减少可能的攻击行为。

当尝试跨域访问资源时会收到以下错误
cors

同源的定义

如果两个 URL 的 协议、域名和端口 都相同的话,则这两个 URL 是同源的。

源的修改

Javascript 脚本可以将 document.domain 的值设置为当前域名或当前域的父域名。如果将其设置为其当前域的父域名时,则这个较短的父域名将用于后续源的检查。任何对 document.domain 的赋值操作,包括 document.domain = document.domain 都会导致端口号被重写为 null 。因此,如果希望子域名和父域名同源,需要在他们双方中都进行赋值。

跨源网络访问

受同源策略的影响,通常,文档允许嵌入跨源的资源,但是不允许对跨源资源进行读取。

资源 权限
内嵌iframe 允许跨源嵌入,但不允许跨域读取(例如使用 JavaScript 访问 iframe 中的文档)
样式CSS 跨源 CSS 可以使用 <link> 元素或在CSS文件中通过 @import 嵌入(Content-Type 需要设置正确)
表单forms 跨源 URLs 可以作为 form 元素的 action 属性
图片images 跨源图片可以通过 <img> 元素嵌入,但是不能读取(例如使用 JavaScript 将跨域图像加载到 canvas 元素中)
多媒体multimedia 可以使用 <video><audio> 元素嵌入跨源视频和音频
脚本script 跨源脚本可以使用 <script> 元素嵌入,但特定 api(例如 跨源的 Fetch API 请求 或者 XMLHttpRequest 请求)会被阻止
API iframe.contentWindowwindow.parentwindow.openwindow.opener 在跨源时 访问 Window 和 Location 对象均被限制
localStorage & IndexedDB 无法跨源访问

同源策略主要限制以下三种情况:

  • DOM同源策略:禁止对不同源页面DOM进行操作。这里主要场景是 iframe 跨域的情况,不同域名的 iframe 是限制互相访问的。
  • Ajax同源策略:禁止使用 XMLHttpRequest 对象 或者 Fetch API 向不同源的服务器地址发起 HTTP 请求。
  • 存储同源策略:禁止读取非同源的 sessionStorage、localStorage、IndexedDB

如何跨域共享资源

同源策略本质上为了对不同的源做资源隔离。但实际应用中,经常有不同的域名属于同一个实体的情况(比如为了突破浏览器并发请求数限制或对静态资源做了CDN配置导致静态资源和API请求不在同一域名下)。在这些情况下同源策略太严格了,给拥有多个域名(或子域)的大型网站跨域资源共享带来了问题。以下列出一些较常见的场景和方法来避免因同源策略导致的资源共享问题。

CORS(Cross Origin Resource Sharing) 跨源资源共享

这种方式使用了一个新的 Origin 请求头和一个新的 Access-Control-Allow-Origin 响应头扩展了HTTP。允许服务端设置 Access-Control-Allow-Origin 头标识哪些站点可以请求文件,或者设置 Access-Control-Allow-Origin: *,表示允许任意站点访问文件。根据请求类型是否是“简单请求”,浏览器可能发送 OPTIONS 类型的预检请求。

对于跨源的 XMLHttpRequest 或 Fetch 请求,浏览器 不会 发送身份凭证信息。如果需要携带身份信息,需要在 XMLHttpRequest 实例中添加 withCredentials 属性,值为 true,如果服务器接受身份信息,则响应头中会包含 Access-Control-Allow-Credentials: true ,这样,浏览器就会发送 Cookie(Cookie 受 SameSite 属性控制) 和 Authorization 信息。

与 CORS 相关的 HTTP 请求头和响应头包括以下这些:

响应

首部字段名 说明 例子
Access-Control-Allow-Origin origin 参数的值指定了允许访问该资源的外域 URI。对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符,表示允许来自所有域的请求。如果服务端指定了具体的域名而非“*”,那么响应首部中的 Vary 字段的值必须包含 Origin。这将告诉客户端:服务器对不同的源站返回不同的内容。 Access-Control-Allow-Origin: <origin> | *
Access-Control-Expose-Headers 让服务器把允许浏览器访问的头放入白名单 Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
Access-Control-Max-Age 指定了preflight请求的结果能够被缓存多久 Access-Control-Max-Age: <delta-seconds>
Access-Control-Allow-Credentials 指定了当浏览器的 credentials 设置为 true 时是否允许浏览器读取 response 的内容 Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods 用于预检请求的响应,指明了实际请求所允许使用的 HTTP 方法 Access-Control-Allow-Methods: <method>[, <method>]*
Access-Control-Allow-Headers 用于预检请求的响应,指明了实际请求中允许携带的首部字段 Access-Control-Allow-Headers: <field-name>[, <field-name>]*

请求

首部字段名 说明 例子
Origin 源站 URI Origin: <origin>
Access-Control-Request-Method 用于预检请求,将实际请求所使用的 HTTP 方法告诉服务器 Access-Control-Request-Method: <method>
Access-Control-Request-Headers 用于预检请求,将实际请求所携带的首部字段告诉服务器 Access-Control-Request-Headers: <field-name>[, <field-name>]*

document.domain 父子域名资源共享

如前所描,如果两个待共享资源的域名是父子域名的关系,或者同属于一个父域名,则可以通过修改 document.domain 将其源改为相同的父域名来实现跨域共享资源。

window.postMessage 跨文档通信

window.postMessage 可以安全地实现跨源通信。

发送窗口
1
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow: 其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames
  • message: 将要发送到其他 window 的数据
  • targetOrigin: 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件
  • transfer(可选) : 是一串和 message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权
接收窗口
1
2
3
4
5
6
7
8
9
window.addEventListener("message", receiveMessage, false);
function receiveMessage(event) {
var origin = event.origin;
// 不检查 origin 属性会导致跨站点脚本攻击(XSS)
if (origin !== "http://example.org:8080")
return;
}
// 获取 event.data 数据,同时 可以使用 event.source 继续和 source 通信
}

WebSocket

WebSocket 规范定义了可在浏览器和服务器之间建立持久连接的 API,这种方式通信没有使用 HTTP,因此也没有跨域的限制。

客户端:

1
2
3
4
5
6
7
8
9
<script>
let socket = new WebSocket("ws://localhost:8080");
socket.onopen = function() {
socket.send("message");
};
socket.onmessage = function(e) {
console.log(e.data);
};
</script>

服务器:

nodejs
1
2
3
4
5
6
7
const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 8080 });
server.on("connection", function(socket) {
socket.on("message", function(data) {
socket.send(data);
});
});

JSONP - JSON with padding (hack)

JSONP 是利用 <script\> 标签没有跨域限制的要求来达到与第三方通讯的目的。JSONP 只能支持 GET 类型的请求。当需要通讯时,源站脚本创建一个 <script\> 元素,地址指向第三方的API地址,形如:

1
<script src="http://www.example.net/api?param1=1&param2=2&func=callback"></script>

并提供一个回调函数来接收数据(函数名可约定,或通过地址参数传递)。
第三方产生的响应为 json 数据的包装(故称之为jsonp,即json with padding),形如:

1
callback({"name":"hax","gender":"Male"})

这样浏览器会调用 callback 函数,并传递解析后 json 对象作为参数。本站脚本可在 callback 函数里处理所传入的数据。

客户端实现:

1
2
3
4
5
6
7
8
9
10
11
function test(data) {
console.log(data)
}
var url="http://www.x.com/test?a=1&callback=test";
// 然后前端通过script标签去访问并执行,上面的东西
var script = document.createElement('script');
script.setAttribute('src', url);
// 把script标签加入head,此时调用开始
document.getElementsByTagName('head')[0].appendChild(script);
// 然后就会调用页面的test方法
// test({})

window.name (hack)

window 对象有个 name 属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个 window.name 的,每个页面对 window.name 都有读写的权限,window.name 是持久存在一个窗口载入过的所有页面中的。

通过请求代理实现跨域

浏览器有跨域限制,但是服务器/代理工具不存在跨域问题,所以可以由同源服务器/代理工具请求所要域的资源再返回给客户端。

  • 服务器代理 - 你可以自己启动一个服务器,然后将接口转发,不过开发服务器一般都有代理工具可以快速对接口进行代理,例如 webpack-dev-server 可以通过配置 devServer.proxy 来配置对接口的代理,底层是使用了 http-proxy-middleware 包进行了处理
  • 代理工具代理 - 多数代理工具提供了接口转发功能,例如 charles 可以在 tools/map remote 中将请求转发到另一个地址
  • Nginx 反向代理 - 将不同接口转发到对应的域名下
1
127.0.0.1 local.test
1
2
3
4
5
6
7
8
9
10
server {
listen 80;
server_name local.test;
location /api {
proxy_pass http://localhost:8080;
}
location / {
proxy_pass http://localhost:8000;
}
}

参考资料

MDN Docs Same-origin policy
w3 Same_Origin_Policy
web.dev Same-origin policy
MDN Docs CORS
MDN Docs window.postMessage