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

2,654 阅读12分钟
原文链接: www.jianshu.com

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做一个最基本的实现。使用Vuenode.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的回调爽多了:

import jsonpRequest from "../lib/jsonpRequest.js";

async sendJSONPRequest() {
    //参数
    let params = {
        name: "老黄",
        site: "www.huangchengdu.com"
    };
    this.showLoading();
    //发送请求
    let {
        err,
        result
    } = await jsonpRequest(
        "https://www.huangchengdu.com/jsonp/jsonpRequest",
        params
    );
    //处理返回的数据
    this.hiddenLoading();
    if (err) {
        alert(err.message || "请求出错了");
        this.serverData.err = JSON.stringify(err);
    } else {
        this.serverData = result;
    }
}

服务端的实现如下。

let express = require('express');
let router = express.Router();
//JSONP请求
router.get('/jsonpRequest', function(req, res, next) {
    //console.log("=====================" + JSON.stringify(req.query));
    //获取name和site参数的值
    let name = req.query.name;
    let site = req.query.site;
    //拼接回调值
    let serverres = {
        serverReceive:{
            name:name,
            site:site
        },
        serverSend:"hello," + name + ".your site is https://" + site
    }
    //返回值。其实就是callback....()种种类型javascript字符串
    res.end(req.query.callback + "(" + JSON.stringify(serverres) + ")")
});
module.exports = router;

服务端代码说明如下:

  • res.endexpress表示对http请求返回。具体返回的数据类似于callback随机数(服务端数据)这种类型。
  • 客户端在收到callback随机数(服务端数据)这个数据以后,会自动按照javascript脚本解析执行。具体就是一个全局方法调用,方法名是callback随机数,参数是服务端数据。这样就实现了服务端数据的回调。
  • 客户端在global对象下面注册了callback随机数这个方法。具体代码是上面global[fnName] = function(ret) {这一行。
  • callback随机数是服务端和客户端商量,具体可以自己决定,真实的时候类似于callbacksuijishu这种类型。

1.2.1 JSONP请求报文

JSONP本质上就是一个普通的GET请求。无非就是这个请求是通过script标签来发送的。而且请求参数里面必定会有一个callback参数。
下面我们具体抓包看一下我们的请求报文:

GET /jsonp/jsonpRequest?name=%E8%80%81%E9%BB%84&site=www.huangchengdu.com&callback=jsonpiFuL4 HTTP/1.1
Host: www.huangchengdu.com
Accept: */*
Connection: keep-alive
Cookie: session=s%3Anot8KTW5FiTLY0VNgrrKksXY96AE2kWT.hrQeyL%2BVjt8ICJjfFqoFdV8JV3lx0IsDntx%2B%2Bc%2FEM98
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/604.4.7 (KHTML, like Gecko) Version/11.0.2 Safari/604.4.7
Accept-Language: zh-cn
Referer: http://localhost:8081/
Accept-Encoding: br, gzip, deflate

返回报文:

HTTP/1.1 200 OK
Server: nginx/1.6.2
Date: Fri, 29 Dec 2017 03:26:31 GMT
X-Powered-By: Express
Transfer-Encoding: chunked
Connection: Keep-alive

jsonpiFuL4({"serverReceive":{"name":"��","site":"www.huangchengdu.com"},"serverSend":"hello,��.your site is https://www.huangchengdu.com"})

从上面的报文我们可以返现。请求的callback参数的值和返回的响应体的名称是一样的。响应提就是一个普通的函数。服务器返回的数据作为函数的参数。

2 XSS攻击

XSS的全称是Cross-site scripting,翻译过来就是跨站脚本script可以跨域加载脚本这个特性,合理利用比如JSONP。如果不合理利用,比如某个坏人通过某种方式,让你的浏览器去加载恶意的javascrpt脚本,必然就会导致敏感信息被盗或者财务损失。最常见的就是XSS攻击,其实就是注入恶意脚本。真是凡事都有利有弊,就看如何使用了。常用的XSS攻击手段和目的有如下几种:

  • 盗用cookie,获取敏感信息。
  • 利用植入Flash,通过crossdomain权限设置进一步获取更高权限;或者利用Java等得到类似的操作。
  • 利用iframe、frame、XMLHttpRequest或上述Flash等方式,以(被攻击)用户的身份执行一些管理动作,或执行一些一般的如发微博、加好友、- 发私信等操作。
  • 利用可被攻击的域受到其他域信任的特点,以受信任来源的身份请求一些平时不允许的操作,如进行不当的投票活动。
  • 在访问量极大的一些页面上的XSS可以攻击一些小型网站,实现DDoS攻击的效果。

如果某一个字符串里面有var a = 1;<script>alert('我是你大爷')</script>;var b = 2;这种类型的字符串。而且我们刚好要通过script标签加载。那么他就会弹出一个我是你大爷。避免的方式就是把存在这种可能性的地方都处理过,如果包含类似<script>这种字符的脚本就处理掉或者干脆返回错误。目前最常见的预防操作有如下几种:

  • 将重要的cookie标记为http only,这样的话Javascript 中的document.cookie语句就不能获取到cookie了。
  • 只允许用户输入我们期望的数据。例如:年龄的textbox中,只允许用户输入数字。 而数字之外的字符都过滤掉。
  • 对数据进行Html Encode处理。
  • 过滤或移除特殊的Html标签,例如:<script>,<iframe>,<for<,>for>,"for。
  • 过滤JavaScript事件的标签。例如"onclick=","onfocus"等等。

3 CSRF攻击

这玩意我了解不多,也无法做出模拟操作。跨站请求伪造(英语:Cross-site request forgery),也被称为one-click attack或者session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。[1] 跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

我的理解就是,比如你刚去淘宝买了东西,并且浏览器有你的session护着cookie之类的信息。然后你马上又进入一个不该去的网站,并且点击了里面的一个淘宝链接,然后在你不知情的情况下做一些违法操作。这样阿里后台是不知道的,因为你刚刚通过合法手段买了东西,从而达到在你不知情的情况下,而且淘宝也信任你的情况下,畏畏缩缩偷偷摸摸的干坏事。

3.1 SCRF预防

检查Referer字段,通过这个字段来判断用户是从那个地址跳转到当前地址的。HTTP头中有一个Referer字段,这个字段用以标明请求来源于哪个地址。在处理敏感数据请求时,通常来说,Referer字段应和请求的地址位于同一域名下。以上文银行操作为例,Referer字段地址通常应该是转账按钮所在的网页地址,应该也位于www.examplebank.com之下。而如果是CSRF攻击传来的请求,Referer字段会是包含恶意网址的地址,不会位于 www.examplebank.com之下,这时候服务器就能识别出恶意的访问。这种办法简单易行,工作量低,仅需要在关键访问处增加一步校验。但这种办法也有其局限性,因其完全依赖浏览器发送正确的Referer字段。虽然http协议对此字段的内容有明确的规定,但并无法保证来访的浏览器的具体实现,亦无法保证浏览器没有安全漏洞影响到此字段。并且也存在攻击者攻击某些浏览器,篡改其Referer字段的可能。

添加校验token,这个就最常见了,现在那个前端网站还不加一个验证码啊。不管你如何千变万化,你验证码中是用户数据的吧,而且现在好像越来越流行手机号码验证了。CSRF的本质在于攻击者欺骗用户去访问自己设置的地址,所以如果要求在访问敏感数据请求时,要求用户浏览器提供不保存在cookie中,并且攻击者无法伪造的数据作为校验,那么攻击者就无法再执行CSRF攻击。这种数据通常是表单中的一个数据项。服务器将其生成并附加在表单中,其内容是一个伪乱数。当客户端通过表单提交请求时,这个伪乱数也一并提交上去以供校验。正常的访问时,客户端浏览器能够正确得到并传回这个伪乱数,而通过CSRF传来的欺骗性攻击中,攻击者无从事先得知这个伪乱数的值,服务器端就会因为校验token的值为空或者错误,拒绝这个可疑请求。