同源策略和跨域请求研究
一、同源策略
假设有一个需求,需要向另外的网站请求数据,例如抓取谷歌搜索的结果。然后写这么一个请求,搜索内容为hello:
var url = "https://www.google.com.hk/?gws_rd=cr,ssl#newwindow=1&safe=strict&q=hello"; $.ajax({ url: url, sucess: function(data){ document.write(data); } });
或者用原生的更直观:
var req = new XMLHttpRequest(); req.open("GET", url); req.send();
执行后,浏览器会报错:
大意是说localhost域名无法向google.com域名请求数据。
因为同源策略的限制,不同域名、协议(http、https)或者端口无法直接进行ajax请求。 同源策略只针对于浏览器端,浏览器一旦检测到请求的结果的域名不一致后,会堵塞请求结果。这里注意,跨域请求是可以发去的,但是请求响应response被浏览器堵塞了。
写了一个程序做验证——用node开了个服务,监听在9000端口,然后在8000端口打开一个页面,再向9000端口的服务发请求:
url = "http://server.com:9000"; $.ajax({ method: "POST", url: url, data: { account: "yin" }, success: function(data){ document.write(data); } });
服务将收到的请求数据打印出来:
服务收到了请求,并正常返回数据,但是返回的数据被浏览器干掉了,即使是返回码也无法得到了。所以说同源策略是限制了不同源的读,但不限制不同源的写。那么我们的问题来了,为什么不直接限制写呢,只限制读有什么好处呢?在回答这个问题之前,先要了解同源策略的作用。
假设我打开了A网银http://Abank.com,已经通过了登陆验证,然后再打开了另外一个黑网站http://evil.com,这个网站刚好是抓使用Abank.com的肉鸡。在evil.com的代码里会向Abank.com发请求,例如转账请求,将余额转到自己的账户。但是由于同源策略的限制,使得这种做法无法成功。这个怎么解释呢?
因为evil.com无法获取你在Abank.com的信息,包括验证身份的信息——通常是按照一定规则生成的无法猜到的随机token字符串。token可能放在cookie里面,从evil.com向Abank发请求时,是不会带上Abank的cookie的,同时也不会带上evil.com的cookie,虽然cookie是和域名绑定的。由于没有正确的token值,导致无法通过服务的身份验证。
为验证没带cookie,在上面的例子,localhost向server.com请求数据,服务将收到的cookie打印出来是undefined:
然而localhost已经设置了cookie:
server.com也有设置cookie:
回到上面的问题,为什么不限制写呢?那是因为如果连请求也不出去,那在源头上就限制死了,网站之间就无法共享资源了。另外,限制读即浏览器拦截请求结果,一般情况下就够了,一方面如果访问的是黑网站,那么网站无法跟据请求结果继续下一步的操作,如不断地猜测密码,另一方面如果访问的是白网站,block掉请求结果,应该是考虑到了请求结果可能会使得页面重定向,或者是给网页添加一个恶意的iframe之类的。
有什么办法可以绕过同源策略?有一个办法就是CSRF攻击
二、CSRF攻击
如上面的例子,由于同源策略的限制,跨域的ajax请求不会带cookie,然而script/iframe/img等标签却是支持跨域的,所以在请求的时候是会带上cookie的。还是上面的例子,如果登陆了Abank.com,那么cookie里面就有了tocken,同时又打开了另外一个标签页访问了evil.com,这个网页里面有一个iframe:
<iframe src="http://Abank.com/app/transferFunds?amount=1500&destinationAccount=... >
这个iframe的src是一个Abank.com的转账的请求,如果Abank.com的转账请求没有第二重加密措施的话,那么请求转账就成功了!
第二个例子是路由器的配置,假设我在网上找到了一个路由器配置教程的网站。这个网站里面偷偷地加一个img标签:
<img src=”http://192.168.1.1/admin/config/outsideInterface?nexthop=123.45.67.89” alt=”pwned” height=”1” width=”1”/>
其中192.168.1.1是很多路由器的配置地址。这个1像素的图片没加载出来被忽略了,但是它的请求却发出去了。这个请求给路由器添加了一个vpn代理,指向黑客的代理服务器。如果路由器也是把登陆验证放在cookie里面,那么这个设置vpn的请求很可能就成功了,以后的连接路由器的每个请求都会先经过黑客的服务。
到这里,很明显一个防CSRF攻击的策略就是将token添加到请求的参数里面,也就是说每个需要验证身份的请求都要显式地带上token值。详见:Cross-Site Request Forgery Guide: Learn All About CSRF Attacks and CSRF Protection
用script引用的外域的资源一方面可以像上面一样当作一个跨域的请求,另外一方面虽然资源是不可见的,但是script里面定义的全局对象是可用的,如引用jQuery的CDN,定义的一个全局对象jQuery。所以根据这个特性,在某些条件下可以获得到script返回的需要登陆才能得到的数据,有兴趣的可参见:Plain text considered harmful: A cross-domain exploit
跨域攻击可以采取一些措施进行规避,但是跨域更多的还是一些实际的正常应用。
三、跨域请求
有时候在自己的网站需要一些去别人的网站请求数据,这个时候就需要跨域正常请求。方法有很多:
1. 跨域资源共享(CORS)
很多天气、IP地址查询的网站就采用了这样的方法,允许其它网站对其请求数据,例如IP location,可以在自己网站的js里面向它发一个get请求:
var url = "https://ipinfo.io/54.169.237.109/json?token=iplocation.net"; document.cookie = "version=1;"; $.ajax({ url: url })
它就会返回ip地址信息,同时不会被浏览器拦截:
观察response的头部,可以发现添加了一个字段:
Access-Control-Allow-Origin就是所谓的资源共享了,它的值*表示允许任意网站向这个接口请求数据,也可以设置成指定的域名,如:
response.writeHead(200, { "Access-Control-Allow-Origin": "http://yoursite.com"});
在node.js服务里面添加这个头,那么只有http://yoursite.com能够正常的进行跨域请求。更多地,还可以指定请求的方式、时间等,详见:HTTP访问控制(CORS)
2. JSONP
另外一个常用的办法是使用jsonp,这个方法的原理是客户端告诉服务一个回调函数的名称,服务在返回的scritp里面调用这个回调函数,同时传进客户端需要的数据,这样返回的代码就在浏览器执行了。
例如8000端口要向9000端品请求数据,在8000端口的页面文件定义一个回调函数writeDate,将writeDate写在script的src的参数里,这个script标签向9000端口发出请求:
<script> function writeDate(_date){ document.write(_date); } </script> <script src="http://192.168.0.103:9000/getDate?callback=writeDate"></script>
服务端返回一个脚本,在这个脚本里面执行writeDate函数:
function getDate(response, callback){ response.writeHead(200, {"Content-Type": "text/javascript"}); var data = "2016-2-19"; response.end(callback + "('" + data + "')"); }
浏览器就执行了这个script片段:
这样就实现了跨域的效果。jQuery的ajax里的jsonp的类型,就是用了这样的办法,只是jQuery将它封装好了,使用起来形式跟普通的get/post一样,但是原理是不一样的。
JSONP和CORS相比较,缺点是只支持get类型,无法支持post等其它类型,必须完全信任提供服务的第三方,优点是兼容性较好。
3. 子域跨父域
子域跨父域是支持的,但是需要显式将子域的域名改成父域的,例如mail.mysite.com要请求mysite.com的数据,那么在mail.mysite.com脚本里需要执行:
document.domain = "mysite.com";
4. iframe跨父窗口
如果iframe与父窗口也有同源策略的限制,父域无法直接读取不同源的iframe的DOM内容以及监听事件,但是iframe可以调用父窗口提供的api。iframe通过window.parent得到父窗口的window对象,然后父窗口定义一个全局对象供iframe调用。
例如在页面通过iframe的方式嵌入一个youtobe的视频,如果需要手动播放视频、监听iframe的播放事件,页面需要引入youtobe的视频播放控制api,在这个js文件里面定义了一个全局对象YT:
if (!window['YT']) {var YT = {loading: 0,loaded: 0};}
而在视频iframe的脚本里通过window.parent获取得到父窗口即自己网站的页面:
sr = new Cq(window.parent, d, b)
自已网站的页面也是在这个YT对象自定义一些东西,如添加播放事件监听:
new YT.Player('video', { events:{ 'onStateChange': function(data){//do sth. } } });
5. window.postMessage
在上面第(4)点,父窗口无法向不同源的iframe传递东西,通过window.postMessage可以做到,父窗口向iframe传递一个消息,而iframe监听消息事件。
例如在8000端口的页面嵌入了一个9000端口的iframe:
<iframe src="http://server.com:9000"></iframe>
然后9000端口post一个message:
window.onload = function(){ window.frames[0].postMessage("hello, this is from http://localhost:8000/", "http://server.com:9000/"); }
postMessage执行的上下文必须是接收信息的window,传递两个参数,第一个是数据,第二个是目标窗口。
同时,iframe即9000端口的页面监听message事件:
window.addEventListener("message", receiveMessage); function receiveMessage(event){ var origin = event.origin || event.originalEvent.origin; //身份验证 if (origin !== "http://localhost:8000"){ return; } console.log("receiveMessage: " + event.data); }
这样子iframe就可收到父窗口的信息了:
同理iframe也可以向父窗口发送消息:
window.parent.postMessage("hello, this is from http://server.com:9000", "http://localhost:8000");
父窗口收到:
window.postMessage也适用于通过window.open打开的子窗口,方法类似。
补充一点,如果iframe与父窗口是同源的,则父窗口可以直接获取到iframe的内容,这个方法常用于无刷新上传文件。