開發(fā)出高性能的 Web 應(yīng)用固然重要,但安全問題也不容小覷。本文我們繼續(xù)以 HTTP 為線索,展開來(lái)講一講瀏覽器安全相關(guān)的同源策略。
瀏覽器的同源策略(Same Origin Policy)
源(Origin)是由 URL 中協(xié)議、主機(jī)名(域名 domain)以及端口共同組成的部分。在下面的網(wǎng)址中,源由協(xié)議 https、主機(jī)名 kaiwu.lagou.com 和默認(rèn)端口 443 共同組成。
URL 中的源
如果兩個(gè) URL 的源相同,我們就稱之為同源。下面的 3 個(gè) URL 和示例 URL 都是不同的源。
http://kaiwu.lagou.com/course/courseInfo.htm?courseId=180#/content:協(xié)議不同。
https://kaiwu.lagou.com:80/course/courseInfo.htm?courseId=180#/content:端口不同。
https://lagou.com/course/courseInfo.htm?courseId=180#/content:主機(jī)名不同。
而下面 2 個(gè)網(wǎng)址和示例 URL 都是同源。
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=288#/sale:請(qǐng)求參數(shù)不同。
https://kaiwu.lagou.com:URL 路徑不同。
當(dāng)一個(gè)源訪問另一個(gè)源的資源時(shí)就會(huì)產(chǎn)生跨源。同源策略就是用來(lái)限制其中一些跨源訪問的,包括訪問 iframe 中的頁(yè)面、其他頁(yè)面的 cookie 訪問以及發(fā)送 AJAX 請(qǐng)求。最常見的跨源場(chǎng)景是域名不同,即常說(shuō)的“跨域”。本課時(shí)也按照約定俗成的說(shuō)法,用“跨域”來(lái)指代“跨源”。
同源策略在保障安全的同時(shí)也帶來(lái)了不少問題,比如 iframe 中的子頁(yè)面與父頁(yè)面無(wú)法通信,瀏覽器與其他服務(wù)端無(wú)法交互數(shù)據(jù)。所以我們需要一些跨域方案來(lái)解決這些問題。
請(qǐng)求跨域解決方案
對(duì)于瀏覽器請(qǐng)求跨域,常用的有下面 4 種方法。
跨域資源共享
跨域資源共享(CORS,Cross-Origin Resource Sharing)是瀏覽器為 AJAX 請(qǐng)求設(shè)置的一種跨域機(jī)制,讓其可以在服務(wù)端允許的情況下進(jìn)行跨域訪問。主要通過 HTTP 響應(yīng)頭來(lái)告訴瀏覽器服務(wù)端是否允許當(dāng)前域的腳本進(jìn)行跨域訪問。
跨域資源共享將 AJAX 請(qǐng)求分成了兩類:簡(jiǎn)單請(qǐng)求和非簡(jiǎn)單請(qǐng)求。其中簡(jiǎn)單請(qǐng)求符合下面 2 個(gè)特征。
請(qǐng)求方法為 GET、POST、HEAD。
請(qǐng)求頭只能使用下面的字段:Accept(瀏覽器能夠接受的響應(yīng)內(nèi)容類型)、Accept-Language(瀏覽器能夠接受的自然語(yǔ)言列表)、Content-Type (請(qǐng)求對(duì)應(yīng)的類型,只限于 text/plain、multipart/form-data、Application/x-www-form-urlencoded)、Content-Language(瀏覽器希望采用的自然語(yǔ)言)、Save-Data(瀏覽器是否希望減少數(shù)據(jù)傳輸量)。
任意一條要求不符合的即為非簡(jiǎn)單請(qǐng)求。
對(duì)于簡(jiǎn)單請(qǐng)求,處理流程如下:
瀏覽器發(fā)出簡(jiǎn)單請(qǐng)求的時(shí)候,會(huì)在請(qǐng)求頭部增加一個(gè) Origin 字段,對(duì)應(yīng)的值為當(dāng)前請(qǐng)求的源信息;
當(dāng)服務(wù)端收到請(qǐng)求后,會(huì)根據(jù)請(qǐng)求頭字段 Origin 做出判斷后返回相應(yīng)的內(nèi)容。
瀏覽器收到響應(yīng)報(bào)文后會(huì)根據(jù)響應(yīng)頭部字段 Access-Control-Allow-Origin 進(jìn)行判斷,這個(gè)字段值為服務(wù)端允許跨域請(qǐng)求的源,其中通配符“*”表示允許所有跨域請(qǐng)求。如果頭部信息沒有包含 Access-Control-Allow-Origin 字段或者響應(yīng)的頭部字段 Access-Control-Allow-Origin 不允許當(dāng)前源的請(qǐng)求,則會(huì)拋出錯(cuò)誤。
當(dāng)處理非簡(jiǎn)單的請(qǐng)求時(shí),瀏覽器會(huì)先發(fā)出一個(gè)預(yù)檢請(qǐng)求(Preflight)。這個(gè)預(yù)檢請(qǐng)求為 OPTIONS 方法,并且添加了 1 個(gè)請(qǐng)求頭部字段 Access-Control-Request-Method,值為跨域請(qǐng)求所使用的請(qǐng)求方法。
下圖是一個(gè)預(yù)檢請(qǐng)求的請(qǐng)求報(bào)文和響應(yīng)報(bào)文。因?yàn)樘砑恿瞬粚儆谏鲜龊?jiǎn)單請(qǐng)求的頭部字段,所以瀏覽器在請(qǐng)求頭部添加了 Access-Control-Request-Headers 字段,值為跨域請(qǐng)求添加的請(qǐng)求頭部字段 authorization。

預(yù)檢請(qǐng)求頭部信息
在服務(wù)端收到預(yù)檢請(qǐng)求后,除了在響應(yīng)頭部添加 Access-Control-Allow-Origin 字段之外,至少還會(huì)添加 Access-Control-Allow-Methods 字段來(lái)告訴瀏覽器服務(wù)端允許的請(qǐng)求方法,并返回 204 狀態(tài)碼。
在上面的例子中,服務(wù)端還根據(jù)瀏覽器的 Access-Control-Request-Headers 字段回應(yīng)了一個(gè) Access-Control-Allow-Headers 字段,來(lái)告訴瀏覽器服務(wù)端允許的請(qǐng)求頭部字段。
瀏覽器得到預(yù)檢請(qǐng)求響應(yīng)的頭部字段之后,會(huì)判斷當(dāng)前請(qǐng)求服務(wù)端是否在服務(wù)端許可范圍之內(nèi),如果在則繼續(xù)發(fā)送跨域請(qǐng)求,反之則直接報(bào)錯(cuò)。
JSONP
JSONP(JSON with Padding)的大概意思就是用 JSON 數(shù)據(jù)來(lái)填充,怎么填充呢?結(jié)合它的實(shí)現(xiàn)方式可以知道,就是把 JSON 數(shù)填充到一個(gè)回調(diào)函數(shù)中。這種比較 hack 的方式,依賴的是 script 標(biāo)簽跨域引用 js 文件不會(huì)受到瀏覽器同源策略的限制。
下面以一個(gè)具體例子來(lái)講解它的具體實(shí)現(xiàn)方式。
假設(shè)我們要在 http://ww.a.com 中向 http://www.b.com 請(qǐng)求數(shù)據(jù)。
1.全局聲明一個(gè)用來(lái)處理返回值的函數(shù) fn,該函數(shù)參數(shù)為請(qǐng)求的返回結(jié)果。
function fn(result) {
console.log(result)
}
2.將函數(shù)名與其他參數(shù)一并寫入 URL 中。
var url = 'http://www.b.com?callback=fn¶ms=...';
3.創(chuàng)建一個(gè) script 標(biāo)簽,把 URL 賦值給 script 的 src。
var script = document.createElement('script');
script.setAttribute("type","text/JAVAscript");
script.src = url;
document.body.appendChild(script);
4.當(dāng)服務(wù)器接收到請(qǐng)求后,解析 URL 參數(shù)并進(jìn)行對(duì)應(yīng)的邏輯處理,得到結(jié)果后將其寫成回調(diào)函數(shù)的形式并返回給瀏覽器。
fn({
list: [],
...
})
5.在瀏覽器收到請(qǐng)求返回的 js 腳本之后會(huì)立即執(zhí)行文件內(nèi)容,即在控制臺(tái)打印傳入的數(shù)據(jù)內(nèi)容。
JSONP 雖然實(shí)現(xiàn)了跨域請(qǐng)求,但也存在 3 個(gè)問題:
- 只能發(fā)送 GET 請(qǐng)求,限制了參數(shù)大小和類型;
- 請(qǐng)求過程無(wú)法終止,導(dǎo)致弱網(wǎng)絡(luò)下處理超時(shí)請(qǐng)求比較麻煩;
- 無(wú)法捕獲服務(wù)端返回的異常信息。
Websocket
Websocket 是 html5 規(guī)范提出的一個(gè)應(yīng)用層的全雙工協(xié)議,適用于瀏覽器與服務(wù)器進(jìn)行實(shí)時(shí)通信場(chǎng)景。
什么叫全雙工呢?
這是通信傳輸?shù)囊粋€(gè)術(shù)語(yǔ),這里的“工”指的是通信方向,“雙工”是指從客戶端到服務(wù)端,以及從服務(wù)端到客戶端兩個(gè)方向都可以通信,“全”指的是通信雙方可以同時(shí)向?qū)Ψ桨l(fā)送數(shù)據(jù)。與之相對(duì)應(yīng)的還有半雙工和單工,半雙工指的是雙方可以互相向?qū)Ψ桨l(fā)送數(shù)據(jù),但雙方不能同時(shí)發(fā)送,單工則指的是數(shù)據(jù)只能從一方發(fā)送到另一方。
下面是一段簡(jiǎn)單的示例代碼。在 a 網(wǎng)站直接創(chuàng)建一個(gè) WebSocket 連接,連接到 b 網(wǎng)站即可,然后調(diào)用 WebScoket 實(shí)例 ws 的 send() 函數(shù)向服務(wù)端發(fā)送消息,監(jiān)聽實(shí)例 ws 的 onmessage 事件得到響應(yīng)內(nèi)容。
var ws = new WebSocket("ws://b.com");
ws.onopen = function(){
// ws.send(...);
}
ws.onmessage = function(e){
// console.log(e.data);
}
代理轉(zhuǎn)發(fā)
跨域是為了突破瀏覽器的同源策略限制,既然同源策略只存在于瀏覽器,那可以換個(gè)思路,在服務(wù)端進(jìn)行跨域,比如設(shè)置代理轉(zhuǎn)發(fā)。這種在服務(wù)端設(shè)置的代理稱為“反向代理”,對(duì)于用戶而言是無(wú)感知的。
另一種在客戶端使用的代理稱為“正向代理”,主要用來(lái)代理客戶端發(fā)送請(qǐng)求,用戶使用時(shí)必須配置代理服務(wù)器的網(wǎng)址,比如常用的 VPN 工具就屬于正向代理。
代理轉(zhuǎn)發(fā)實(shí)現(xiàn)起來(lái)非常簡(jiǎn)單,在當(dāng)前被訪問的服務(wù)器配置一個(gè)請(qǐng)求轉(zhuǎn)發(fā)規(guī)則就行了。
下面的代碼是 webpack-dev-server 配置代理的示例代碼。當(dāng)瀏覽器發(fā)起前綴為 /api 的請(qǐng)求時(shí)都會(huì)被轉(zhuǎn)發(fā)到 http://localhost:3000 這個(gè)網(wǎng)址,然后將響應(yīng)結(jié)果返回給瀏覽器。對(duì)于瀏覽器而言還是請(qǐng)求當(dāng)前網(wǎng)站,但實(shí)際上已經(jīng)被服務(wù)端轉(zhuǎn)發(fā)。
// webpack.config.js
module.exports = {
//...
devServer: {
proxy: {
'/api': 'http://localhost:3000'
}
}
};
在 Nginx 服務(wù)器上配置同樣的轉(zhuǎn)發(fā)規(guī)則也非常簡(jiǎn)單,下面是示例配置。
location /api {
proxy_pass http://localhost:3000;
}
通過 location 指令匹配路徑,然后通過 proxy_pass 指令指向代理地址即可。
頁(yè)面跨域解決方案
除了瀏覽器請(qǐng)求跨域之外,頁(yè)面之間也會(huì)有跨域需求,例如使用 iframe 時(shí)父子頁(yè)面之間進(jìn)行通信。
postMessage
HTML5 推出了一個(gè)新的函數(shù) postMessage() 用來(lái)實(shí)現(xiàn)父子頁(yè)面之間通信,而且不論這兩個(gè)頁(yè)面是否同源。
舉例來(lái)說(shuō),如果父頁(yè)面 https://lagou.com 要向子頁(yè)面 https://kaiwu.lagou.com 發(fā)消息,可以通過下面的代碼實(shí)現(xiàn)。
// https://lagou.com
var child = window.open('https://kaiwu.lagou.com');
child.postMessage('hi', 'https://kaiwu.lagou.com');
上面的代碼通過 window.open() 函數(shù)打開了子頁(yè)面,然后調(diào)用 child.postMessage() 函數(shù)發(fā)送了字符串?dāng)?shù)據(jù)“hi”給子頁(yè)面。
在子頁(yè)面中,只需要監(jiān)聽“message”事件即可得到父頁(yè)面的數(shù)據(jù)。代碼如下:
// https://kaiwu.lagou.com
window.addEventListener('message', function(e) {
console.log(e.data);
},false);
同樣的,父頁(yè)面也可以監(jiān)聽“message”事件來(lái)接收子頁(yè)面發(fā)送的數(shù)據(jù)。子頁(yè)面發(fā)送數(shù)據(jù)時(shí)則要通過 window.opener 對(duì)象來(lái)調(diào)用 postMessage() 函數(shù)。
// https://kaiwu.lagou.com
window.opener.postMessage('hello', 'https://lagou.com');
改域
對(duì)于主域名相同,子域名不同的情況,可以通過修改 document.domain 的值來(lái)進(jìn)行跨域。如果將其設(shè)置為其當(dāng)前域的父域,則這個(gè)較短的父域?qū)⒂糜诤罄m(xù)源檢查。
比如,有一個(gè)頁(yè)面,它的地址是 https://www.lagou.com/parent.html,在這個(gè)頁(yè)面里面有一個(gè) iframe,其 src 是 http://kaiwu.lagou.com/child.html。
這時(shí)只要把 http://www.lagou.com/parent.html 和 http://kaiwu.lagou.com/child.html 這兩個(gè)頁(yè)面的 document.domain 都設(shè)成相同的域名,那么父子頁(yè)面之間就可以進(jìn)行跨域通信了,同時(shí)還可以共享 cookie。
但要注意的是,只能把 document.domain 設(shè)置成更高級(jí)的父域才有效果,例如在 http://kaiwu.lagou.com/child.html 中可以將 document.domain 設(shè)置成 kaiwu.lagou.com。
總結(jié)
本文介紹了瀏覽器的同源策略,并分別從請(qǐng)求跨域與頁(yè)面跨域兩個(gè)方向介紹了幾種常用的跨域方案。
對(duì)于請(qǐng)求跨域,包括跨域資源共享、JSONP、Websocket、代理轉(zhuǎn)發(fā) 4 種方式,推薦優(yōu)先使用代理轉(zhuǎn)發(fā)和跨域資源共享。對(duì)于頁(yè)面跨域,包括 postMessage 和改域 2 種方式,使用頻率沒有請(qǐng)求跨域那么高,記住 2 種方式實(shí)現(xiàn)原理就好。
最后留一道思考題:說(shuō)一說(shuō)你還知道瀏覽器的哪些安全策略?