跨域
# 同源策略
含义:
- 协议相同
- 域名相同
- 端口相同
同源策略目的: 浏览器为了保证用户信息的安全,防止恶意的网站窃取数据。
同源策略限制范围:
- Cookie、LocalStorage 和 IndexDB 无法读取
- DOM 无法获得
- AJAX 请求不能发送
# 无同源策略下将会产生的问题
举两个🌰,简单描述一下在没有同源策略的情况下,产生的安全问题
# 接口请求场景中的身份证明(跨站请求伪造)
拿 Cookie 举例,如果没有同源策略,访问 www.a.com
的时候能获取到你之前访问的 www.alipay.com
的时候服务设置的 Cookie ,然后通过该 Cookie 作为你在 www.alipay.com
上面的身份证明,调用接口将支付宝里的钱全部转走
那么恭喜你,你中了CSRF攻击(跨站请求伪造),钱没了,人也没了
# 钓鱼网站通过dom查询拿到用户输入信息
某天跟女朋友聊天,摔了一跤手机摔坏了,想登录网页版微信,结果不小心搜到一个伪造的微信登录的入口,很简单的做了下面的操作:
<iframe name="weixin" src="web.weixin.com"></iframe>
// 由于没有同源策略的限制,钓鱼网站可以直接拿到别的网站的Dom
<script>
const iframe = window.frames['weixin'];
const dom = iframe.contentWindow.document;
const account = dom.getElementById('account').value;
const password = dom.getElementById('password').value;
// 拿到你的账号密码,登录微信,给你女朋友发消息说:我们分手吧
</script>
然而你并不知道,依然熟练的输入了用户名和密码,登录上去之后发现女朋友骂了你几句,把你拉黑了
# 跨域方案
由于生产场景中大多数业务逻辑代码可能并不关系是否跨域,只是单纯的想要去当前不同域的一个地方拿个资源,方式可能就是发个http请求啥的。因此此处讨论的跨域考虑的场景偏向于封装成服务模块,但是其中的原理不变
# CORS
CORS
(Cross-origin resource sharing),意为跨域资源共享,是标准的跨域解决方案
请求分为简单请求和非简单请求
- 简单请求 - 需满足以下要求
- 请求方法是:
HEAD
、GET
、POST
- 请求头信息不超出以下几种字段:
- Accept - 可处理的媒体类型
- Accept-Language - 可接受的自然语言
- Content-Language - 实体主体的自然语言
- Last-Event-ID - 最后一个事件ID
- Content-Type - 实体主类的类型,只限于三个值
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- 请求方法是:
- 非简单请求 - 除简单请求,其他的均为非简单请求
- 非简单请求的表现形式是:先发出一次预检测请求,返回码是204,预检测通过才会真正发出请求,才返回200
# 简单请求的跨域方案
前端正常请求资源,后端设置响应头 ctx.set('Access-Control-Allow-Origin', '*')
,注意此时 cookie 不会在http请求中带上
# 非简单请求的跨域方案
后端需设置接口的响应头
class CrossDomain {
static async cors (ctx) {
const query = ctx.request.query
// 如果需要http请求中带上cookie,需要前后端都设置credentials,且后端设置指定的origin
ctx.set('Access-Control-Allow-Origin', 'http://localhost:9099')
ctx.set('Access-Control-Allow-Credentials', true)
// 非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)
// 这种情况下除了设置origin,还需要设置Access-Control-Request-Method以及Access-Control-Request-Headers
ctx.set('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t')
ctx.cookies.set('tokenId', '2')
ctx.body = successBody({msg: query.msg}, 'success')
}
}
module.exports = CrossDomain
// 或者直接使用插件(koa2),来设置这些请求头:koa2-cors
app.use(cors({
origin: function (ctx) {
return 'http://host:9099'
},
credentials: true,
allowMethods: ['GET', 'POST', 'DELETE'],
allowHeaders: ['t', 'Content-Type']
}))
前端需要配合设置额外的 headers ,触发非简单请求
fetch(`http://somehost/api/v1/cors?msg=helloCors`, {
// 需要带上cookie
credentials: 'include',
headers: {
't': 'extra headers'
}
}).then(res => {
console.log(res);
});
# postMessage API
一般为处理跨域页面下的 DOM 操作
通过两个域下的消息传递,可将己方的数据结构化克隆序列化之后传递给另一个域下,另一个域通过监控 message 事件获取到来自对方的数据
# jsonp(script标签跨域)
# 原理
利用 <script>
标签可以跨域的机制,同时需要前后端配合
- 前端
- 提供请求之后的 Global EC 下的jsonp回调函数,并将函数名称作为jsonp参数
- 提供接口的请求,方式为执行一段脚本,实则指定src为服务接口,带上jsonp参数(可以动态插入)
- 后端
- 处理接口,获取资源,返回一段由前端执行的脚本,脚本中执行jsonp回调函数,将资源作为参数传入
# 缺陷
只能处理 GET 请求
# 一个最简单的示例
// 后端(koa2)
class ApiController {
static async jsonp (ctx) {
const query = ctx.request.query;
// 获取到的接口资源
const data = { data: '123' };
if (query.callback) {
// 作为jsonp,返回执行回调函数脚本
ctx.body = `${query.callback}(${JSON.stringify(data)})`;
} else {
// 作为普通接口,返回资源数据
ctx.body = data;
}
}
}
module.exports = ApiController;
<!-- 前端 -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type='text/javascript'>
window.jsonpCb = function (res) {
console.log(res); // { data: '123' }
}
</script>
<script src='http://somehost/api/v1/someSource?callback=jsonpCb' type='text/javascript'>
// 设置了callback,接口将返回脚本,在此处执行
</script>
</body>
</html>
# 模拟成生产场景下的jsonp请求模块
/**
* 拼接参数query
* @param {Object} data 需要拼接成 query 的对象,可选 { a: 123, b: 456 } -> 'a=123&b=456'
*/
const createQueryStr: (data?: Obj) => string =
(data = {}) => data
? Object.keys(data).reduce(
(q: string, k: string) => q += (q === '' ? '' : '&') + `${k}=${data[k]}`, ''
)
: '';
/**
* 生成一定范围内的随机数
* @param {Number} min 最小值
* @param {Number} max 最大值
*/
const randomInRange: (min: number, max: number) => number =
(min, max) => Math.floor(Math.random() * (max - min) + min);
/**
* JSONP请求
* @param url 请求的地址
* @param data 请求的参数
* @returns {Promise<any>}
*/
const requestByJsonp = ({ url, data }) => new Promise((resolve, reject) => {
// 动态创建script标签
const script = document.createElement('script');
// 为处理高并发场景冲突,每个jsonp请求回调函数名称均携带chunk,此处规则为取6位随机数
const jsonpCbName = 'JsonpCb' + randomInRange(100000, 999999);
// 设置jsonp回调函数,此处直接将接口返回的数据 resolve 抛出
!window[jsonpCbName] && window[jsonpCbName] = res => {
document.body.removeChild(script);
delete window[jsonpCbName];
resolve(res);
}
script.src = `${url}?${createQueryStr(data)}&callback=${jsonpCbName}`;
document.body.appendChild(script);
});
// 使用方式
requestByJsonp({
url: 'http://somehost/api/v1/someSource',
data: {
// 传参
msg: '123'
}
}).then(res => {
console.log(res);
});
# 提交表单(form标签跨域)
可使用 <form>
表单标签完成跨域的 GET、POST 请求,其中需指定 form 的 target 为一个新生成的 iframe 负责请求数据
此处需要注意的是:返回值存到 iframe 的标签中,结构是这样的:
<iframe>
> #document
...
<pre><!-- 此处为 iframe 调用接口之后的返回值,格式为json --></pre>
...
</iframe>
因此完整方案如下
- 通过构造一个子页面,监控 load 事件,父子页面放在同一个域(origin)下
- 父页面动态插入 iframe ,并通过构造 form 表单,将 target 指向 iframe.name ,submit 请求
- 子页面在 load 事件完成时通过 postMessage 将
document.getElementsByTagName('pre')[0].innerHTML
获取到接口的返回传递到父页面 - 父页面接收到 message 事件,再构造 promise 通过 resolve 包装到外界
<!-- 用于通过 form 表单跨域请求的子页面 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script>
window.addEventListener('load', () => {
if (window && window.self !== window.top) {
// 说明在 iframe 内
// 获取请求的返回
const res = document.getElementsByTagName('pre')[0].innerHTML;
// 向父窗口发送数据
window.opener.postMessage(res, window.location.origin);
}
});
</script>
</body>
</html>
// 父窗口的服务模块
const requestPost = ({ url, method, data }) => {
// 参数校验,若是 GET 可能需要拼接 data 为 query ,此处只默认处理 POST 情况
// 首先创建一个用来发送数据的iframe
const iframe = document.createElement('iframe');
iframe.src = ''; // 此处应填子页面的地址
iframe.name = 'iframeCrossDomain';
iframe.style.display = 'none';
document.body.appendChild(iframe);
// 创建 form 标签以及 负责准备数据的input
let form = document.createElement('form');
let input = document.createElement('input');
form.style.display = 'none';
form.action = url;
// 指定 form 的 target 为一个新生成的 iframe
form.target = iframe.name;
form.method = method;
// 此处为 POST 请求包装参数
for (let name in data) {
input.name = name;
input.value = data[name].toString();
form.appendChild(input.cloneNode());
}
// 表单元素需要添加到主文档中.
document.body.appendChild(form);
form.submit();
// GC
let GC = () => {
document.body.removeChild(form);
document.body.removeChild(iframe);
GC = form = input = null;
}
return new Promise((resolve, reject) => {
// 注册iframe的load事件,将拿到的请求返回抛出
window.addEventListener('message', e => {
const event = e || window.event;
const res = event.data || {};
resolve(res);
GC();
});
});
}
// 使用方式
requestPost({
url: 'http://somehost/api/v1/someSource',
method: 'POST',
data: {
msg: '123'
}
}).then(res => {
console.log(res);
});
以上模块可以基本覆盖大部分跨域业务场景,接入生产时做一定错误处理即可
# domain
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置 document.domain
共享 Cookie。
例子:
- A网页:map1.baidu.com/a.html 设置了
document.domain = 'abc'
- B网页:map2.baidu.com/b.html 设置了
document.domain = 'abc'
此时A网页设置的 document.cookie = '3n89f34'
,也可以在B网页获取到
注意:
这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API
另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如:
Set-Cookie: key=value; domain=abc; path=/
# 如何设置同源策略(hosts)
test1.xxx.com/a.html
<script>
document.domain = 'test2.xxx.com' // 设置同源策略
document.cookie = '3n89f34'
</script>
test2.xxx.com/a.html
<script>
document.cookie // 3n89f34
</script>
最常用的策略:由服务端设置 cookie
的 domain
# nginx代理
此方法只需配置 nginx ,前端基本不需要做处理
server{
listen 8080;
server_name localhost;
# 凡是somehost:80/api/v1,都转发到真正的服务端地址 http://somehost/api/v1/
location ^~ /api/v1 {
proxy_pass http://somehost/api/v1/;
}
}
# 其他方法
- WebSocket,可以设置对应的
Origin
- 将代码写到img中,利用base64