一文搞懂跨域问题和同源策略
1.什么是同源策略
基本概念
chatGPT 的描述:同源策略(Same-Origin Policy)是一种安全机制,被浏览器用于防止不同源(协议、域名、端口)的页面之间进行恶意的交互。它是为了保护用户的隐私和数据安全而设计的。同源策略限制了不同源的页面在浏览器中访问彼此的资源,以及在页面中运行脚本时的权限。
借用 MDN 上的描述:同源策略是一个重要的安全策略,它用于限制一个 origin 的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。
例子描述:同源策略跟汽车限行政策有一点点像,同源策略规定在“同源”下的脚本、文档等资源访问不受限(有点像 粤A
牌的汽车在广州内行驶不会被限行),如果不是“同源”则无法互相访问脚本、文档等资源(非 粤A
牌的汽车在广州市区内受开四停四政策限制)
什么是同源(同源策略的规则)
- 协议相同: 页面中的协议(如 HTTP、HTTPS)必须与请求的资源的协议相同。
- 域名相同: 页面中的域名(主域名)必须与请求的资源的域名相同。子域名不同也被视为不同源,例如,
example.com
和sub.example.com
是不同的源。 - 端口相同: 如果页面指定了端口号(非默认端口),则请求的资源也必须使用相同的端口号。
注意:同源策略是浏览器的行为!!!
同源策略限制的能力(同源策略的影响):
非同源的两个页面(在针对 ajax 时,是页面与服务器之间),不能互相访问对方设置的本机存储数据、不能互相操作对方的 DOM、ajax 不能互相发送请求。
Cookies、LocalStorage、SessionStorage 和 IndexedDB: 页面 A 设置的 Cookie 等 无法被页面 B 访问,从而保护用户的隐私。
例子:
DOM 访问限制: 通过脚本修改其他源的 DOM 结构被禁止,防止恶意网站劫持用户界面。
XMLHttpRequest 限制: 使用 XMLHttpRequest 或 Fetch API 发送的跨域请求受到限制,只能访问同一源的资源。
iframe 安全性: 通过 iframe 引入的跨域网页内容不能被脚本访问,以防止恶意代码获取数据。——> 本质上也是对于 DOM 的访问限制
为什么要有同源策略?
浏览器主要是从两个方面去做这个同源策略的,一是针对接口的请求,二是针对 Dom 的查询。
假设没有同源策略:
1.没有同源策略限制的接口请求的例子
有一个东西叫cookie,一般用于处理登录等场景,目的是让服务端知道是谁发出的这次请求。如果请求了登录接口,服务端验证通过后会在响应头加入Set-Cookie字段,然后下次再发请求的时候,浏览器会自动将cookie附加到 HTTP 请求的头字段Cookie中(但是有了跨域的时候,浏览器是不会在跨域请求中默认携带 cookie 的,除非进行了专门的设置),服务端就能知道这个用户已经登录过了。知道了上述之后,来看以下场景:
- 你准备去清空购物车,于是打开了某买买买网站,登录成功之后,一看购物车东西这么少,不行,还得多买点,于是继续浏览。
- 当你在浏览的过程中,你的好基友给你发了一个链接,一脸 yin 笑地对你说:“你懂的”,于是你毫不犹豫得打开了。
- 你饶有兴致地浏览着某网站,谁知这个网站暗地里做了些不可描述的事情!由于没有同源策略的限制,它向某买买买网站发起了请求!聪明的你一定想到上面的话“服务端验证通过后会在响应头加入 Set-Cookie 字段,然后下次再发请求的时候,浏览器会自动将 cookie 附加在 HTTP 请求的头字段 Cookie 中”,这么一来,这个不法网站就相当于登录了你的账号,可以为所欲为了!如果这不是一个买买买账号,而是你的银行账号,那……
这就是传说中的CSRF攻击。看了这波 CSRF 攻击之后,心里有个疑问?
即使有了同源策略限制,但 cookie 是明文的,还不是一样能拿下来。于是看了一些 cookie 相关的文章,知道了服务端可以设置HttpOnly,使得前端无法操作 cookie,如果没有这样的设置,像XSS攻击就可以去获取到 cookie;设置secure,则保证在 https 的加密通信中传输以防截获。——> 所以说同源策略可以一定程度上防止 CSRF 和 XSS,但是不能完全避免!
2.没有同源策略限制的 Dom 查询获取的例子
- 有一天你刚睡醒,收到一封邮件,说是你的银行账号有风险,赶紧点进www.yinghang.com网站改密码,你果断输入你的账号密码,登录进去看看钱有没有少了。
- 睡眼朦胧的你没看清楚,平时访问的银行网站是www.yinhang.com,而现在访问的是www.yinghang.com,这个钓鱼网站做了什么呢?
<iframe name="yinhang" src="www.yinhang.com"></iframe>
<!--嵌入真实的页面-->
<script>
// 由于没有同源策略的限制,钓鱼网站可以直接拿到别的网站的Dom
const iframe = window.frames["yinhang"];
const node = iframe.document.getElementById("你输入账号密码的Input");
console.log(`拿到了这个${node},我还拿不到你刚刚输入的账号密码吗`);
</script>
由此可以知道,同源策略确实能规避一些危险,但不是说有了同源策略就安全,只是说同源策略是一种浏览器最基本的安全机制,毕竟能提高一点攻击的成本。
2.什么是跨域问题,跨域问题的触发条件
什么是跨域问题:
在一台服务器的页面中,请求另一台服务器的数据。这种行为就是跨域(两个非同源服务器互相请求数据)。
这里产生的问题就是跨域问题。
跨域问题发生的条件:
1.两个服务之间违背了同源策略,不同源
2.在一台服务器(服务)的页面中,请求另一台服务器(服务)的数据
3.ajax 请求 ——> 这个条件最重要
跨域发生的阶段,具体位置:
跨域是发生在浏览器端,与网络请求无关!!!
实际上之前的浏览器甚至不支持发送跨域请求到服务器,但是发展到现在 W3C 制定了 CORS 标准了,Chrome
已经支持发送跨域请求,但是服务端传回来的报文却没有使用该标准的响应头,所以浏览器无法解析,浏览器将其判定为跨域响应,不会接受。
所以说:能请求,能返回,但是不接收。
注意: 因为同源策略是浏览器的行为,所以跨域问题只会影响浏览器端的 JavaScript 代码,对于服务端代码来说是不存在跨域问题的。因此,如果是服务端代码访问其他服务,是不会受到跨域问题的限制的。
3.解决跨域问题的方法大全集 9 种
1.JSONP 原生跨域解决(只支持 get 请求)(浏览器与服务器)
全称 JSON with padding,是一个非官方的跨域解决方案。
jsonp 的优点就是兼容性好,可以解决主流浏览器的跨域问题,缺点是仅支持 GET 请求,不安全,可能遭受 xss 攻击。
标签实现跨域:
原理:网页中有些标签本身就具备跨域能力,比如:img,link,iframe,script,a 等。
注意:a 标签跨域是利用 src 属性可以跨域请求的特点来实现的。只不过 a 标签跨域仅是针对他的 download 属性。
JSONP 的原理:
JSONP 是利用 script 标签的跨域能力来发送请求的。
JSONP 的使用:
通过<script>
标签src
属性,发送带有callback
参数的GET
请求,服务端将接口返回数据拼凑到callback
函数中,返回给浏览器,浏览器解析执行,从而前端拿到 callback 函数返回的数据。
原生 JS 的写法:
<script>
var script = document.createElement("script");
script.type = "text/javascript";
// 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
script.src =
"http://www.domain2.com:8080/login?user=admin&callback=handleCallback";
document.head.appendChild(script);
// 回调执行函数
function handleCallback(res) {
alert(JSON.stringify(res));
}
</script>
服务端返回如下(返回时即执行全局函数):因为 script 标签请求的是 js 脚本,所以服务端返回的当然就是 js 脚本,这样 html 会直接运行
handleCallback({ success: true, user: "admin" });
服务端写法:nodejs 为例
var querystring = require("querystring");
var http = require("http");
var server = http.createServer();
server.on("request", function (req, res) {
var params = querystring.parse(req.url.split("?")[1]);
var fn = params.callback;
// jsonp返回设置
res.writeHead(200, { "Content-Type": "text/javascript" }); //返回的类型是js脚本
res.write(fn + "(" + JSON.stringify(params) + ")"); //拼接成json字符串即可
res.end();
});
server.listen("8080");
console.log("Server is running at port 8080...");
使用 jquery 实现 jsonp
$.ajax({
url: "http://www.domain2.com:8080/login",
type: "get",
dataType: "jsonp", // 请求方式为jsonp
jsonpCallback: "handleCallback", // 自定义回调函数名
data: {},
});
使用 axios 实现 jsonp(vue 中)
this.$http = axios;
this.$http
.jsonp("http://www.domain2.com:8080/login", {
params: {},
jsonp: "handleCallback",
})
.then((res) => {
console.log(res);
});
2.跨域资源共享解决跨域(CORS 技术)(浏览器与服务器)
cors 是跨域资源共享,是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),使得浏览器允许这些 origin 访问加载自己的资源。服务端设置了 Access-Control-Allow-Origin 就开启了 CORS,所以这种方式只要后端实现了 CORS,就解决跨域问题,前端不需要配置。
//服务器设置CORS,允许浏览器跨域
res.setHeader("Access-Control-Allow-Origin", "*");
普通跨域请求:只服务端设置 Access-Control-Allow-Origin 即可,前端无须设置,若要带 cookie 请求:前后端都需要设置。
目前,所有浏览器都支持该功能(IE8+:IE8/9 需要使用 XDomainRequest 对象来支持 CORS),CORS 也已经成为主流的跨域解决方案。
跨域要携带 cookie 的话需要前端配置
vue 框架
a.) axios 设置:
axios.defaults.withCredentials = true;
b.) vue-resource 设置:
Vue.http.options.credentials = true;
后端配置
Nodejs
服务端设置 Access-Control-Allow-Origin 响应头即可
const http = require("http");
const url = require("url");
// 创建server
const server = http.createServer();
// 定义跨域访问白名单
const authOrigin = ["http://127.0.0.1:5500"];
// 监听http请求
server.on("request", (req, res) => {
const user = {
// 模拟返回数据
id: 1,
name: "zhangsan",
age: 12,
};
const origin = req.headers.origin;
if (authOrigin.includes(origin)) {
// 添加响应头,实现cors
// res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的地址跨域访问
res.setHeader("Access-Control-Allow-Origin", origin); // 只有白名单中的地址才可以跨域访问
}
res.end(JSON.stringify(user));
});
// 设置监听端口
server.listen(8081, function () {
console.log("server is running on 8081 port!");
});
3.正向代理解决跨域(浏览器与服务器):搭建 Node 代理服务器
注意:可以用 vue-cli 和 vite 等脚手架配置正向代理
但是,通过 Vue CLI、Vite 或其他类似的脚手架配置代理是主要针对开发阶段的。这些脚手架提供了开发服务器,可以在开发环境中方便地配置代理,以解决跨域问题或将请求转发到其他服务器。
一旦想要生产环境还可以正向代理的话,就需要使用 node 自己搭建代理服务器了!
所以生产环境一般考虑用 nginx 的方式来解决跨域
这里我们演示原生的 node 方法搭建代理服务器
因为同源策略是浏览器限制的,所以服务端请求服务器是不受浏览器同源策略的限制的,因此我们可以搭建一个自己的 node 服务器来代理访问服务器。
大概的流程就是:我们在客户端请求自己的 node 代理服务器,然后在 node 代理服务器中转发客户端的请求访问服务器,服务器处理请求后给代理服务器响应数据,然后在代理服务器中把服务器响应的数据再返回给客户端。客户端和自己搭建的代理服务器之间也存在跨域问题(如果是脚手架配置的就不存在跨域了),所以需要在代理服务器中设置 CORS。
node 代理服务器代码:
/**通过nodeJS搭建自己的代理服务器来解决跨域问题 */
const axios = require("axios");
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
// 使用第三方插件
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// 监听post请求,处理代理接口
app.post("/proxyApi", (req, res) => {
const { body } = req;
// 获取post请求的请求参数
let reqParams = {};
for (const key in body) {
reqParams = JSON.parse(key); // 获取到请求参数
}
// 设置响应头
// 代理服务器设置CORS,允许跨域访问
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "*"); // 允许所有的请求方式
const { url, method = "GET", ...resConfig } = reqParams || {};
// node代理服务器再用axios请求真正的服务器
axios({
url,
method,
...resConfig,
})
.then((result) => {
const { status, headers, data } = result;
res.status(status);
res.setHeader("content-type", headers["content-type"]);
res.end(JSON.stringify(data)); // 给客户端返回数据
})
.catch((err) => {
res.end(JSON.stringify(err));
});
});
// 监听请求
app.listen(8081, () => {
console.log("服务启动成功,在8081端口监听请求....");
});
4.反向代理:Nginx 解决跨域(浏览器与服务器)
nginx 通过反向代理解决跨域也是利用了服务器请求服务器不受浏览器同源策略的限制实现的。
客户端请求 nginx 服务器,在 nginx.conf 配置文件中配置 server 监听客户端的请求,然后把 location 匹配的路径转发代理到真实的服务器,服务器处理请求后返回数据,nginx 再把数据给客户端返回。大致流程如下:
注意:nginx 反向代理方式和 node 中间件代理方式的原理其实差不多,都是利用了服务器和服务器之间通信不受浏览器的同源策略的限制,但是 node 代理方式相对复杂一些,还要自己搭建一个 node 服务器,而用 nginx 只需要修改 nginx.conf 配置文件即可解决跨域问题。
node 是从源头进行身份改变(代理了前端),而 nginx 是在接口处统一管理转发(代理了后端)。
5.postMessage 方式解决跨域(任意页面间)
postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一
window.postMessage() 方法可以安全地实现跨源通信,此方法一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。
主要的用途是实现多窗口,多文档之间通信:
- 页面和其打开的新窗口的数据传递
- 多窗口之间消息传递
- 页面与嵌套的 iframe 消息传递
一句话:主要是不同源的两个页面之间进行通讯,常用于和 iframe 之间通讯
因为使用了监听器来接收数据,所以可以保证安全性!
用法:postMessage(data,origin)方法接受两个参数:
data:html5 规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用 JSON.stringify()序列化。
origin:协议+主机+端口号,也可以设置为"*“,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为”/"。
1.)a.html:(http://www.domain1.com/a.html)
<iframe
id="iframe"
src="http://www.domain2.com/b.html"
style="display:none;"></iframe>
<script>
var iframe = document.getElementById("iframe");
iframe.onload = function () {
var data = {
name: "aym",
};
// 向domain2传送跨域数据
iframe.contentWindow.postMessage(
JSON.stringify(data),
"http://www.domain2.com"
);
};
// 接受domain2返回数据
window.addEventListener(
"message",
function (e) {
alert("data from domain2 ---> " + e.data);
},
false
);
</script>
2.)b.html:(http://www.domain2.com/b.html)
<script>
// 接收domain1的数据
window.addEventListener(
"message",
function (e) {
alert("data from domain1 ---> " + e.data);
var data = JSON.parse(e.data);
if (data) {
data.number = 16;
// 处理后再发回domain1
window.parent.postMessage(
JSON.stringify(data),
"http://www.domain1.com"
);
}
},
false
);
</script>
6.WebSocket 协议实现跨域(浏览器与服务器)
WebSocket protocol 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是 server push 技术的一种很好的实现。
WebSocket 规范定义了一个在 Web 浏览器和服务器之间建立“套接字”连接的 API。 简单来说:客户端和服务器之间存在持久连接,双方可以随时开始发送数据。
原生 WebSocket API 使用起来不太方便,我们使用 Socket.io,它很好地封装了 webSocket 接口,提供了更简单、灵活的接口,也对不支持 webSocket 的浏览器提供了向下兼容。
1.)前端代码:
<div>user input:<input type="text" /></div>
<script src="./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>
2.)Nodejs socket 后台:
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.");
});
});
7.document.domain(iframe 标签下)解决不同子域的 iframe 跨域通讯
此方案仅限主域相同,子域不同的跨域应用场景。
实现原理:两个页面都通过 js 强制设置 document.domain 为基础主域,就实现了同域。
主域和子域?
- 主域(Main Domain): 主域是域名的基本部分,是网站的主要标识。它是在域名中最高层级的部分,例如在
www.example.com
中,example.com
就是主域。主域通常用来标识整个网站,可以在主域下创建多个子域。 - 子域(Subdomain): 子域是在主域之下创建的,它是主域的一个分支或子集。子域在域名中位于主域之前,并通过一个点来分隔,例如在
blog.example.com
中,blog
就是子域。子域通常用于划分网站的不同功能、部分或服务,使它们可以独立管理、访问和部署。
一句话:这里相当于是页面和页面之间互相获取内容(iframe 的情况下),而不是发送请求
domain 的主要作用就是让不同子域同主域的域名之间不会跨域,可以访问
a. 父窗口:(http://www.domain.com/a.html)
<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
document.domain = "domain.com";
var user = "admin";
</script>
b. 子窗口:http://child.domain.com/b.html
<script>
document.domain = "domain.com";
// 获取父窗口中变量
alert("get js data from parent ---> " + window.parent.user);
</script>
8.location.hash(iframe 标签下)解决不同域的 iframe 跨域单向通讯
实现原理:a 欲与 b 跨域相互通信,通过中间页 c 来实现。三个页面,不同域之间利用 iframe 的 location.hash 传值(拼接在网页链接后面),相同域之间直接 js 回调函数的方式访问来通信。
一句话:这里相当于是页面和页面之间互相获取内容(iframe 的情况下),而不是发送请求
hash 的主要作用就是让不同域的嵌套 iframe 可以实习单向通讯(父到子)!
而同域的 iframe 直接本身就可以直接通讯!——> 并不是这里要说的内容
具体实现:A 域:a.html -> B 域:b.html -> A 域:c.html,a 与 b 不同域只能通过 hash 值单向通信,b 与 c 也不同域也只能单向通信,但 c 与 a 同域,所以 c 可通过 parent.parent 访问 a 页面所有对象。
1)a.html:(http://www.domain1.com/a.html)
<iframe
id="iframe"
src="http://www.domain2.com/b.html"
style="display:none;"></iframe>
<script>
var iframe = document.getElementById("iframe");
// 向b.html传hash值
setTimeout(function () {
iframe.src = iframe.src + "#user=admin";
}, 1000);
// 开放给同域c.html的回调方法
function onCallback(res) {
alert("data from c.html ---> " + res);
}
</script>
2)b.html:(http://www.domain2.com/b.html)
<iframe
id="iframe"
src="http://www.domain1.com/c.html"
style="display:none;"></iframe>
<script>
var iframe = document.getElementById("iframe");
// 监听a.html传来的hash值,再传给c.html
window.onhashchange = function () {
iframe.src = iframe.src + location.hash;
};
</script>
3)c.html:(http://www.domain1.com/c.html)
<script>
// 监听b.html传来的hash值
window.onhashchange = function () {
// 再通过操作同域a.html的js回调,将结果传回
window.parent.parent.onCallback(
"hello: " + location.hash.replace("#user=", "")
);
};
</script>
9.window.name 解决不同域页面的跨域通讯(仅限于获取 name)
window.name 属性的独特之处:window 对象上的 name 值在不同页面(甚至不同域名)加载切换后依旧存在(相当于保存了历史加载页面的 name),并且可以支持非常长的 name 值(2MB)。
一句话:利用一个同源中间页面+iframe,获取跨域页面的 name 信息(仅限于获取 name)
1)html:(http://www.domain1.com/a.html)
var proxy = function (url, callback) {
var state = 0;
var iframe = document.createElement("iframe"); //用js新建iframe标签
// 加载跨域页面
iframe.src = url;
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
iframe.onload = function () {
if (state === 1) {
//第2次onload
// 第2次onload(同域proxy页)成功后,读取同域window.name中数据(这里就保存了跨域页www.domain2.com/b.html的name信息)
callback(iframe.contentWindow.name);
destoryFrame();
} else if (state === 0) {
//第1次onload
// 第1次onload(跨域页)成功后,切换到同域代理页面
iframe.contentWindow.location = "http://www.domain1.com/proxy.html";
state = 1;
}
};
document.body.appendChild(iframe);
// 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域iframe js访问)
function destoryFrame() {
iframe.contentWindow.document.write("");
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
// 运行函数:请求跨域b页面数据
proxy("http://www.domain2.com/b.html", function (data) {
alert(data);
});
2)proxy.html:(http://www.domain1.com/proxy.html) ——> 就是一个中间页面,利用同源情况下可以获取该页面 name 的特性
中间代理页,与 a.html 同域,内容为空即可。
3)html:(http;//www.domain2.com/b.html)
<script>
window.name = "This is domain2 data!"; //这是要传递给a.html的信息
</script>
通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。