跨域


2019/6/18

# 同源策略

含义:

  • 协议相同
  • 域名相同
  • 端口相同

同源策略目的: 浏览器为了保证用户信息的安全,防止恶意的网站窃取数据。

同源策略限制范围:

  • 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),意为跨域资源共享,是标准的跨域解决方案

请求分为简单请求和非简单请求

  • 简单请求 - 需满足以下要求
    1. 请求方法是: HEADGETPOST
    2. 请求头信息不超出以下几种字段:
      • Accept - 可处理的媒体类型
      • Accept-Language - 可接受的自然语言
      • Content-Language - 实体主体的自然语言
      • Last-Event-ID - 最后一个事件ID
      • Content-Type - 实体主类的类型,只限于三个值
        1. application/x-www-form-urlencoded
        2. multipart/form-data
        3. 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

window.postMessage MDN

一般为处理跨域页面下的 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>

因此完整方案如下

  1. 通过构造一个子页面,监控 load 事件,父子页面放在同一个域(origin)下
  2. 父页面动态插入 iframe ,并通过构造 form 表单,将 target 指向 iframe.name ,submit 请求
  3. 子页面在 load 事件完成时通过 postMessage 将 document.getElementsByTagName('pre')[0].innerHTML 获取到接口的返回传递到父页面
  4. 父页面接收到 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>

最常用的策略:由服务端设置 cookiedomain

# 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
Last Updated: 1/3/2020, 2:42:52 PM