一、同源策略
假設有一個需求,需要向另外的網站請求數據,例如抓取谷歌搜索的結果。然后寫這么一個請求,搜索內容為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的內容,這個方法常用於無刷新上傳文件。