前后端数据交互经常会碰到请求跨域,什么是跨域,以及有哪几种跨域方式,这是本文要探讨的内容。
# 一、什么是跨域?
# 1.什么是同源策略及其限制内容?
同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。
下表给出了与 URL http://store.company.com/dir/page.html
的源进行对比的示例:
URL | 结果 | 原因 |
---|---|---|
http://store.company.com/dir2/other.html | 同源 | 只有路径不同 |
http://store.company.com/dir/inner/another.html | 同源 | 只有路径不同 |
https://store.company.com/secure.html | 失败 | 协议不同 |
http://store.company.com:81/dir/etc.html | 失败 | 端口不同 ( http:// 默认端口是80) |
http://news.company.com/dir/other.html | 失败 | 主机不同 |
同源策略限制内容有:
Cookie、LocalStorage、IndexedDB 等存储性内容
DOM 节点
AJAX 请求发送后,结果被浏览器拦截了
以下是可能嵌入跨源的资源的一些示例:
<script src="..."></script>
标签嵌入跨域脚本。语法错误信息只能被同源脚本中捕捉到。<link rel="stylesheet" href="...">
标签嵌入CSS。由于CSS的松散的语法规则 (opens new window),CSS的跨域需要一个设置正确的 HTTP 头部Content-Type
。不同浏览器有不同的限制: IE (opens new window), Firefox (opens new window), Chrome (opens new window), Safari (opens new window) (跳至CVE-2010-0051)部分 和 Opera (opens new window)。- 通过
<img>
展示的图片。支持的图片格式包括PNG,JPEG,GIF,BMP,SVG,... - 通过
<video>
和<audio>
播放的多媒体资源。 - 通过
<object>
,<embed>
和<applet>
嵌入的插件。 - 通过
@font-face
引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts)。 - 通过
<iframe>
载入的任何资源。站点可以使用 X-Frame-Options (opens new window) 消息头来阻止这种形式的跨域交互。
# 二、如何解决跨域?
# 1.CORS
跨域资源共享(CORS (opens new window)) 是一种机制,它使用额外的 HTTP (opens new window) 头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。
而在 cors 中会有 简单请求
和 复杂请求
的概念。
# 1) 简单请求
只要同时满足以下两大条件,就属于简单请求
条件1:使用下列方法之一:
- GET
- HEAD
- POST
条件2:Content-Type 的值仅限于下列三者之一:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器; XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
# 2) 复杂请求
不符合以上条件的请求就肯定是复杂请求了。 复杂请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求,该请求是 option 方法的,通过该请求来知道服务端是否允许跨域请求。
我们用PUT
向后台请求时,属于复杂请求,后台需做如下配置:
// 允许哪个方法访问我
res.setHeader('Access-Control-Allow-Methods', 'PUT')
// 预检的存活时间
res.setHeader('Access-Control-Max-Age', 6)
// OPTIONS请求不做任何处理
if (req.method === 'OPTIONS') {
res.end()
}
// 定义后台返回的内容
app.put('/getData', function(req, res) {
console.log(req.headers)
res.end('我不爱你')
})
# 3)Node 中的解决方案
# 原生方式
我们来看下后端部分的解决方案。Node
中 CORS
的解决代码.
app.use(async (ctx, next) => {
ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
ctx.set("Access-Control-Allow-Credentials", true);
ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
ctx.set(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, cc"
);
if (ctx.method === "OPTIONS") {
ctx.status = 204;
return;
}
await next();
});
# 第三方中间件
为了方便也可以直接使用中间件
const cors = require("koa-cors");
app.use(cors());
# 关于 cors 的 cookie 问题
想要传递 cookie
需要满足 3 个条件
1.web 请求设置withCredentials
这里默认情况下在跨域请求,浏览器是不带 cookie 的。但是我们可以通过设置 withCredentials
来进行传递 cookie
.
// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;
2.Access-Control-Allow-Credentials
为 true
3.Access-Control-Allow-Origin
为非 *
这里请求的方式,在 chrome
中是能看到返回值的,但是只要不满足以上其一,浏览器会报错,获取不到返回值。
浏览器支持情况
当你使用 IE<=9, Opera<12, or Firefox<3.5 或者更加老的浏览器,这个时候请使用 JSONP
# 小结
1、 在新版的 chrome 中,如果你发送了复杂请求,你却看不到 options
请求。可以在这里设置 chrome://flags/#out-of-blink-cors
设置成 disbale
,重启浏览器。对于非简单请求就能看到 options
请求了。
2、 一般情况下后端接口是不会开启这个跨域头的,除非是一些与用户无关的不太重要的接口。
# 2.jsonp
# 1) JSONP原理
利用 <script>
标签没有跨域限制的漏洞,网页可以得到从其他来源动态产生的 JSON 数据。JSONP请求一定需要对方的服务器做支持才可以。
# 2) JSONP和AJAX对比
JSONP和AJAX相同,都是客户端向服务器端发送请求,从服务器端获取数据的方式。但AJAX属于同源策略,JSONP属于非同源策略(跨域请求)
# 3) JSONP优缺点
JSONP优点是简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。缺点是仅支持get方法具有局限性,不安全可能会遭受XSS攻击。
# 4) JSONP的实现流程
1.前端定义解析函数(例如 jsonpCallback=function(){....})
2.通过 params 形式包装请求参数,并且声明执行函数(例如 cb=jsonpCallback)
3.后端获取前端声明的执行函数(jsonpCallback),并以带上参数并调用执行函数的方式传递给前端。
使用示例
后端实现
const Koa = require("koa");
const fs = require("fs");
const app = new Koa();
app.use(async (ctx, next) => {
if (ctx.path === "/api/jsonp") {
const { cb, msg } = ctx.query;
ctx.body = `${cb}(${JSON.stringify({ msg })})`;
return;
}
});
app.listen(8080);
普通 js 示例
<script type="text/javascript">
window.jsonpCallback = function(res) {
console.log(res);
};
</script>
<script
src="http://localhost:8080/api/jsonp?msg=hello&cb=jsonpCallback"
type="text/javascript"
></script>
JQuery Ajax 示例
<script src="https://cdn.bootcss.com/jquery/3.5.0/jquery.min.js"></script>
<script>
$.ajax({
url: "http://localhost:8080/api/jsonp",
dataType: "jsonp",
type: "get",
data: {
msg: "hello"
},
jsonp: "cb",
success: function(data) {
console.log(data);
}
});
</script>
原理解析
其实这就是 js 的魔法
我们先来看最简单的 js 调用。嗯,很自然的调用。
<script>
window.jsonpCallback = function(res) {
console.log(res);
};
</script>
<script>
jsonpCallback({ a: 1 });
</script>
我们稍稍改造一下,外链的形式。
<script>
window.jsonpCallback = function(res) {
console.log(res);
};
</script>
<script src="http://localhost:8080/api/a.js"></script>
// http://localhost:8080/api/a.js jsonpCallback({a:1});
我们再改造一下,我们把这个外链的 js 就当做是一个动态的接口,因为本身资源和接口一样,是一个请求,也包含各种参数,也可以动态化返回。
<script>
window.jsonpCallback = function(res) {
console.log(res);
};
</script>
<script src="http://localhost:8080/api/a.js?a=123&cb=jsonpCallback"></script>
// http://localhost:8080/api/a.js jsonpCallback({a:123});
小结:
1.由于script/iframe/img等标签的请求默认是能带上cookie(cookie里面带上了登陆验证的票token),用这些标签发请求是能够绕过同源策略的,因此就可以利用这些标签做跨站请求伪造(CSRF)
2.动态ajax请求默认是不带cookie的,如果你要带cookie,可以设置ajax的一个属性withCredentials,如下代码所示:
// 原生请求
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open("GET", "http://otherdomain.com/list");
xhr.send();
// jquery请求
$.ajax({
url: "http://otherdomain.com/list",
xhrFields: {
withCredentials: true
}
});
复制代码
# 3.postMessage
postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain
(opens new window)设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。它可用于解决以下方面的问题:
- 页面和其打开的新窗口的数据传递
- 多窗口之间消息传递
- 页面与嵌套的iframe消息传递
- 上面三个场景的跨域数据传递
postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。
otherWindow.postMessage(message, targetOrigin, [transfer]);
- message: 将要发送到其他 window的数据。
- targetOrigin:通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。
- transfer(可选):是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
# 示例
index.html
<iframe
src="http://localhost:8080"
frameborder="0"
id="iframe"
onload="load()"
></iframe>
<script>
function load() {
iframe.contentWindow.postMessage("加油站", "http://localhost:8080");
window.onmessage = e => {
console.log(e.data); // 老板,加95汽油
};
}
</script>
another.html
<div>hello</div>
<script>
window.onmessage = e => {
console.log(e.data); // 加油站
e.source.postMessage('老板,加95汽油', e.origin);
};
</script>
# 4.websocket
Websocket是HTML5的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。WebSocket和HTTP都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。
原生WebSocket API使用起来不太方便,我们使用Socket.io
,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。
我们先来看个例子:本地文件socket.html向`localhost:8080发生数据和接受数据
// socket.html
<script>
let socket = new WebSocket('ws://localhost:8080');
socket.onopen = function () {
socket.send('加油吧,骚年');//向服务器发送数据
}
socket.onmessage = function (e) {
console.log(e.data);//接收服务器返回的数据 //一起加油!
}
</script>
// server.js
let express = require('express');
let app = express();
let WebSocket = require('ws');//记得安装ws
let wss = new WebSocket.Server({port:8080});
wss.on('connection',function(ws) {
ws.on('message', function (data) {
console.log(data); //加油吧,骚年
ws.send('一起加油!')
});
})
# 5.window.name + Iframe
window 对象的 name 属性是一个很特别的属性,当该 window 的 location 变化,然后重新加载,它的 name 属性可以依然保持不变。
其中 a.html 和 b.html 是同域的,都是http://localhost:8000
,而 c.html 是http://localhost:8080
// a.html
<iframe
src="http://localhost:8080/name/c.html"
frameborder="0"
onload="load()"
id="iframe"
></iframe>
<script>
let first = true;
// onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
function load() {
if (first) {
// 第1次onload(跨域页)成功后,切换到同域代理页面
iframe.src = "http://localhost:8000/name/b.html";
first = false;
} else {
// 第2次onload(同域b.html页)成功后,读取同域window.name中数据
console.log(iframe.contentWindow.name);
}
}
</script>
b.html 为中间代理页,与 a.html 同域,内容为空。
// b.html
<div></div>
// c.html
<script>
window.name = "加油站";
</script>
通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。
# 6.window.location.hash + Iframe
# 实现原理
原理就是通过 url 带 hash ,通过一个非跨域的中间页面来传递数据。
# 实现流程
一开始 a.html 给 c.html 传一个 hash 值,然后 c.html 收到 hash 值后,再把 hash 值传递给 b.html,最后 b.html 将结果放到 a.html 的 hash 值中。 同样的,a.html 和 b.htm l 是同域的,都是 http://localhost:8000
,而 c.html 是http://localhost:8080
// a.html
<iframe src="http://localhost:8080/hash/c.html#name1"></iframe>
<script>
console.log(location.hash);
window.onhashchange = function() {
console.log(location.hash);
};
</script>
// b.html
<script>
window.parent.parent.location.hash = location.hash;
</script>
// c.html
<body></body>
<script>
console.log(location.hash);
const iframe = document.createElement("iframe");
iframe.src = "http://localhost:8000/hash/b.html#name2";
document.body.appendChild(iframe);
</script>
# 7.document.domain + iframe
该方式只能用于二级域名相同的情况下,比如 a.test.com
和 b.test.com
适用于该方式。 只需要给页面添加 document.domain ='test.com'
表示二级域名都相同就可以实现跨域。
实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。
我们看个例子:页面a.test.cn:3000/a.html
获取页面b.test.cn:3000/b.html
中a的值
// a.html
<body>
helloa
<iframe src="http://b.test.cn:3000/b.html" frameborder="0" onload="load()" id="frame"></iframe>
<script>
document.domain = 'test.cn'
function load() {
console.log(frame.contentWindow.a);
}
</script>
</body>
// b.html
<body>
hellob
<script>
document.domain = 'test.cn'
var a = 100;
</script>
</body>
# 8.Node 正向代理
代理的思路为,利用服务端请求不会跨域的特性,让接口和当前站点同域。
代理前
代理后
这样,所有的资源以及请求都在一个域名下了。
# a.cli 工具中的代理
# 1) Webpack (4.x)
在webpack
中可以配置proxy
来快速获得接口代理的能力。
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: {
index: "./index.js"
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist")
},
devServer: {
port: 8000,
proxy: {
"/api": {
target: "http://localhost:8080"
}
}
},
plugins: [
new HtmlWebpackPlugin({
filename: "index.html",
template: "webpack.html"
})
]
};
修改前端接口请求方式,改为不带域名。(因为现在是同域请求了)
<button id="getlist">获取列表</button>
<button id="login">登录</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
axios.defaults.withCredentials = true;
getlist.onclick = () => {
axios.get("/api/corslist").then(res => {
console.log(res.data);
});
};
login.onclick = () => {
axios.post("/api/login");
};
</script>
# # (opens new window)2) Vue-cli 2.x
// config/index.js
...
proxyTable: {
'/api': {
target: 'http://localhost:8080',
}
},
...
# 3) Vue-cli 3.x
// vue.config.js 如果没有就新建
module.exports = {
devServer: {
port: 8000,
proxy: {
"/api": {
target: "http://localhost:8080"
}
}
}
};
# 4) Parcel (2.x)
// .proxyrc
{
"/api": {
"target": "http://localhost:8080"
}
}
看到这里,心里一句 xxx 就会脱口而出,一会写配置文件,一会 proxyTable ,一会 proxy,怎么这么多的形式?学不动了学不动了。。。不过也不用慌,还是有方法的。以上所有配置都是有着共同的底层包 http-proxy-middleware (opens new window) .里面需要用到的各种 websocket
,rewrite
等功能,直接看这个库的配置就可以了。关于 http-proxy-middleware 这个库的原理可以看我这篇文章 https://github.com/hua1995116/proxy 当然了。。。对于配置的位置入口还是非统一的。教一个搜索的技巧吧,上面配置写哪里都不用记的,想要哪个框架的 直接 google 搜索 xxx proxy 就行了。
例如 vue-cli 2 proxy 、 webpack proxy 等等....基本会搜到有官网的配置答案,通用且 nice。
# b.使用自己的代理工具
cors-anywhere (opens new window)
服务端
// Listen on a specific host via the HOST environment variable
var host = process.env.HOST || "0.0.0.0";
// Listen on a specific port via the PORT environment variable
var port = process.env.PORT || 7777;
var cors_proxy = require("cors-anywhere");
cors_proxy
.createServer({
originWhitelist: [], // Allow all origins
requireHeader: ["origin", "x-requested-with"],
removeHeaders: ["cookie", "cookie2"]
})
.listen(port, host, function() {
console.log("Running CORS Anywhere on " + host + ":" + port);
});
前端代码
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
axios.defaults.withCredentials = true;
getlist.onclick = () => {
axios
.get("http://127.0.0.1:7777/http://127.0.0.1:8080/api/corslist")
.then(res => {
console.log(res.data);
});
};
login.onclick = () => {
axios.post("http://127.0.0.1:7777/http://127.0.0.1:8080/api/login");
};
</script>
效果展示
缺点
无法专递 cookie,原因是因为这个是一个代理库,作者觉得中间传递 cookie 太不安全了。https://github.com/Rob--W/cors-anywhere/issues/208#issuecomment-575254153
# c.charles
# 介绍
这是一个测试、开发的神器。介绍与使用 (opens new window)
利用 charles 进行跨域,本质就是请求的拦截与代理。
在 tools/map remote
中设置代理
# 前端代码
<button id="getlist">获取列表</button>
<button id="login">登录</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
axios.defaults.withCredentials = true;
getlist.onclick = () => {
axios.get("/api/corslist").then(res => {
console.log(res.data);
});
};
login.onclick = () => {
axios.post("/api/login");
};
</script>
# 后端代码
router.get("/api/corslist", async ctx => {
ctx.body = {
data: [{ name: "秋风的笔记" }]
};
});
router.post("/api/login", async ctx => {
ctx.cookies.set("token", token, {
expires: new Date(+new Date() + 1000 * 60 * 60 * 24 * 7)
});
ctx.body = {
msg: "成功",
code: 0
};
});
# 效果
访问 http://localhost:8000/charles
完美解决。
唔。这里又有一个注意点。在 Mac mojave 10.14
中会出现 charles
抓不到本地包的情况。这个时候需要自定义一个域名,然后配置hosts
指定到127.0.0.1
。然后修改访问方式 http://localhost.charlesproxy.com:8000/charles
。
# 9.Nginx 反向代理
# 介绍
Nginx 则是通过反向代理的方式,(这里也需要自定义一个域名)这里就是保证我当前域,能获取到静态资源和接口,不关心是怎么获取的。nginx 安装教程 (opens new window)
配置下 hosts
127.0.0.1 local.test
配置 nginx
server {
listen 80;
server_name local.test;
location /api {
proxy_pass http://localhost:8080;
}
location / {
proxy_pass http://localhost:8000;
}
}
启动 nginx
sudo nginx
重启 nginx
sudo nginx -s reload
# 实现
前端代码
<script>
axios.defaults.withCredentials = true;
getlist.onclick = () => {
axios.get("/api/corslist").then(res => {
console.log(res.data);
});
};
login.onclick = () => {
axios.post("/api/login");
};
</script>
后端代码
router.get("/api/corslist", async ctx => {
ctx.body = {
data: [{ name: "加油站" }]
};
});
router.post("/api/login", async ctx => {
ctx.cookies.set("token", token, {
expires: new Date(+new Date() + 1000 * 60 * 60 * 24 * 7)
});
ctx.body = {
msg: "成功",
code: 0
};
});
# 效果
访问 http://local.test/charles
浏览器显示
# 10.浏览器开启跨域
其实讲下其实跨域问题是浏览器策略,源头是他,那么能否能关闭这个功能呢?
答案是肯定的。
注意事项: 因为浏览器是众多 web 页面入口。我们是否也可以像客户端那种,就是用一个单独的专门宿主浏览器,来打开调试我们的开发页面。例如这里以 chrome canary 为例,这个是我专门调试页面的浏览器,不会用它来访问其他 web url。因此它也相对于安全一些。当然这个方式,只限于当你真的被跨域折磨地崩溃的时候才建议使用以下。使用后,请以正常的方式将他打开,以免你不小心用这个模式干了其他的事。
# Windows
找到你安装的目录
.\Google\Chrome\Application\chrome.exe --disable-web-security --user-data-dir=xxxx
# Mac
~/Downloads/chrome-data
这个目录可以自定义.
/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary --disable-web-security --user-data-dir=~/Downloads/chrome-data
或者你也可以打开命令窗口,输入命令 open -a "Google Chrome" --args --disable-web-security --allow-file-acess-from-files --user-data-dir=~/Downloads/chrome-data
# 效果展示
嗯,使用起来很香,但是再次提醒,一般情况千万别轻易使用这个方式,这个方式好比七伤拳,使用的好威力无比,使用不好,很容易伤到自己。