51好读  ›  专栏  ›  xibeifeng

JSONP的原理与实现以及XSS,CSRF的相亲相爱

xibeifeng  · 掘金  ·  · 2017-12-29 09:52

正文

1 JSONP的原理与实现


1.1 同源策略

前端跨域是每个前端人绕不过的坎,也是必须了解的一个知识点。我记得第一次遇到前端跨域这个坑的时候,真是无语到极点,对于一个移动端出身的人来说,觉得这个玩意无可理喻。但是后来慢慢了解,觉得前端的同源策略是非常有必要的。同源策略就是浏览器默认让 www.baidu.com 不能加载来自 www.google.com 的数据。对于现在来说,所有数据都是同源的可能性基本上很小,比如我们公司静态资源 www.image.com 和前端资源 www.htmlcss.com 的CDN路径都不一样,前端获取后台数据 www.apidata.com 又是另一个地址。如何解决这个坑呢?我们公司通过两种方式来避开。具体就是通过设置 Access-Control-Allow-Origin 来做 POST 请求,用 JSONP 来实现 GET 请求,因为 JSONP 只能实现 GET 请求。

1.1.1 通过Access-Control-Allow-Origin支持跨域

有些人肯定就纳闷了,我就喜欢跨域,我就不关注安全,难道就没有办法了吗?当然是否定的。你需要做的,只是让服务器在返回的header里面加上 Access-Control-Allow-Origin 这个域就可以了。这样浏览器在接收到服务器返回的数据,就不会因为违反同源策略限制你拿到数据了。下面就用抓包来具体看一下:

当我打开 这里点开h5链接 这个链接的时候。会去 https//m.ctrip.com 通过 POST 请求数据,这里就用到了跨域。

:method: POST
:authority: m.ctrip.com
:scheme: https
:path: /restapi/xyz
content-length: 290
pragma: no-cache
cache-control: no-cache
accept: application/json
origin: https://pages.ctrip.com
user-agent: Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Mobile Safari/537.36
content-type: application/json;charset=UTF-8
referer: https://pages.ctrip.com/ztrip
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7

{请求体,post请求的参数}

服务器返回的响应头如下:

:status: 200
server: Tengine/2.1.2
date: Thu, 28 Dec 2017 11:01:29 GMT
content-type: application/json;charset=utf-8
access-control-allow-origin: *
access-control-expose-headers: RootMessageId
cache-control: private
vary: Accept-Encoding
clogging_trace_id: 8196881814119217567
rootmessageid: 921812-0a0e0de1-420683-219524
x-powered-by: CTrip/SOA2.0 Win32NT/.NET
soa20-response-status: Success
x-aspnet-version: 4.0.30319
x-powered-by: ASP.NET
x-gate: ctrip-gate
x-gate-instance: unknown
x-originating-url: http://m.ctrip.com/xyz
x-gate-remote-call-cost: 9
content-encoding: gzip
slb-http-protocol-version: HTTP/2.0
access-control-expose-headers: slb-http-protocol-version

{服务器返回的有用数据}

我们可以看到,这里有 access-control-allow-origin 这个响应域就解决了问题。这个方法是最简单的,而且前端 POST 请求最常见的方法(不确定还有其他好的解决方案)。这种方式最好就是通过他获取服务数据,不要加载js脚本。小心被别人注入攻击。

1.1.2 JSONP的基本原理

JSONP 之前,我先亮出一段常见的代码。下面这个方法主要就是动态的创建一个 script 标签,然后设置src属性。并且添加到 document 的第一个 script 标签之前。也就是说动态去加载一个 javscript 脚本。

function loadJs(src, attrs = {}) {
    return new Promise((resolve, reject) => {
        const ref = document.getElementsByTagName('script')[0]
        //创建一个scrpt标签
        const script = document.createElement('script')
        //设置script标签的资源路径
        script.src = src
        script.async = true
        //设置属性
        for (let key in attrs) {
            script.setAttribute(key, attrs[key])
        }
        //script标签加入document中
        ref.parentNode.insertBefore(script, ref)
        script.onload = resolve
        script.onerror = reject
    })
}

最有意思的是 script 标签的 src 不受跨域限制。也就是说 wwww.baidu.com 的文件可以通过上面这个方法无限制的加载 www.google.com 的js文件。这个就是 JSONP 的实现的最基本原理。每一个 JSONP 请求就是动态的创建 script 元素,然后通过src属性去加载数据,而且一般是通过callback这个回调方法来返回服务器数据,然后再把 script 标签移除。如此周而复始的循环,想想都累啊。下面看一个JSON的标准格式,服务器会获取到 callback 这个回调方法。然后通过 方法调用 的方式把数据返回来,也就是执行 callbackFun 方法。 serverdata 就是服务器给客户端的数据。至于 callback 这个名字,可以自己定义,有客户端和服务器商量决定。

function callbackFun(serverdata){
    console.log(serverdata)
}
<script src="http://wwww.baidu.com/jsonp.js?callback=callbackFun"></script>

1.2 JSONP的实现

下面我会对 JSONP 做一个最基本的实现。使用 Vue node.js 分别实现客户端和服务端, 代码地址

首先我们先看客户端的实现:

//获取header的第一个子元素
let container = document.getElementsByTagName("head")[0];
/**
 * 生成随机字符串
 */
function makeid() {
    var text = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    for (var i = 0; i < 5; i++)
        text += possible.charAt(Math.floor(Math.random() * possible.length));
    return text;
}
/**
 * jsonp请求的实现。返回一个promise对象对应请求成功和请求失败。
 * @param {*请求的url} url 
 * @param {*请求的参数} options 
 */
function jsonpRequest(url, options) {
    return new Promise((resolve, reject) => {
        try {
            if (!url) {
                reject({
                    err: new Error("url不能为空"),
                    result: null
                });
            }
            if (!document || !global) {
                reject({
                    err: new Error("系统环境有问题"),
                    result: null
                });
            }
            //创建一个script元素
            let scriptNode = document.createElement("script");
            //请求参数
            let data = options || {};
            //回调函数的具体值,服务器和客户端就根据这个方法名来确定请求与返回数据之间的对应。
            let fnName = "jsonp" + makeid();
            // 把callback加入请求参数中
            data["callback"] = fnName;
            // 拼接url
            var params = [];
            //参数的拼接与处理
            for (let [key, value] of Object.entries(data)) {
                params.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]));
            }
            url = (url.indexOf("?")) > 0 ? (url + "&") : (url + "?");
            url += params.join("&");
            //把处理好的url赋值给script元素的src属性。
            scriptNode.src = url;
            // 把回调函数暴露为全局方法。script加载回来以后,会执行fnName对应的这个方法。
            global[fnName] = function(ret) {
                    resolve({
                        err: null,
                        result: ret
                    })
                    //请求完成。删除script元素
                    container.removeChild(scriptNode);
                    //全局对象中删除已经请求完成的回调方法
                    delete global[fnName];
                }
            // script元素遇到错误
            scriptNode.onerror = function(err) {
                reject({
                    err: err,
                    result: null
                })
                //删除script元素和全局回调方法
                container.removeChild(scriptNode);
                global[fnName] && delete global[fnName];
            }
            //指定元素类型
            scriptNode.type = "text/javascript";
            //把script元素添加到header元素中。到这里script元素就会自动加载src。也就是我们的请求发出去了。
            container.appendChild(scriptNode)
        } catch (error) {
            //异常处理捕获
            reject({
                err: error,
                result: null
            });
        }
    });
}

export default jsonpRequest;

这段代码主要做了如下几件事:

  • 创建一个 script 标签元素,并且添加到 header 元素里面。
  • 拼接 script 元素的 src 属性,其中必然好汉callback这个参数,服务端根据这个参数的值回调。
  • 回调以后需要手动把 script 标签元素移除,并且删除全局的回调函数名。

客户端的使用如下,是不是感觉简洁明了,比ES5的回调爽多了:







请到「今天看啥」查看全文