為什么瀏覽器要限制跨域訪問?
背景
Ted Nelson 曾經(jīng)談過文學(xué)二神 —— 讀者和作家,它們都是無敵的:作家可以想寫什么就些什么,而讀者可以選擇什么也不讀。不過現(xiàn)在我們有三個神,讀者、作家還有中間人,它們?nèi)我环蕉疾皇菬o所不能的。
瀏覽器一直在做的假設(shè)是,任何時候只要用戶開始使用互聯(lián)網(wǎng)應(yīng)用,這個應(yīng)用就開始嘗試攻擊這個用戶,也就是說互聯(lián)網(wǎng)應(yīng)用是默認不可信的,瀏覽器在它們可能做的任何事情上都加了限制,而且也對互聯(lián)網(wǎng)應(yīng)用開發(fā)者提供有限的關(guān)于這些限制的報錯信息。因此這是一個對程序非常不友善的環(huán)境,除非這個程序是一個很簡單的不訪問任何互聯(lián)網(wǎng)數(shù)據(jù)的程序。
跨站腳本攻擊(XSS)
最早迫使瀏覽器采用不信任互聯(lián)網(wǎng)應(yīng)用這個設(shè)計思想的攻擊方式,就是跨站點腳本攻擊。在這種攻擊中,一個惡意的人員刀疤哥會向普通用戶愛麗絲 發(fā)送一封電子郵件,郵件里有一個指向刀疤哥的網(wǎng)站的鏈接。愛麗絲毫無防備心地點擊鏈接,讓瀏覽器加載網(wǎng)頁刀疤網(wǎng),瀏覽器允許 JavaScript 程序默認在任何網(wǎng)頁上運行,因此刀疤網(wǎng)上會有一個 JS 程序在愛麗絲的設(shè)備上運行,訪問愛麗絲設(shè)備上她能訪問的一些數(shù)據(jù),然后秘密發(fā)送數(shù)據(jù)到刀疤哥的服務(wù)器上。
程序可以通過各種方式訪問 愛麗絲的私有數(shù)據(jù)。一種方式是愛麗絲正在使用的計算機可能位于防火墻內(nèi),該防火墻允許她進行隱式訪問她家中的網(wǎng)絡(luò)攝像頭或她大學(xué)的期刊庫等等資源。之所以說隱式訪問,是因為這些訪問可以在沒有與愛麗絲進行任何交互的情況下完成,或者也可以通過要求她登錄系統(tǒng)以讓程序獲得一些憑據(jù)(例如 Cookie)來完成。網(wǎng)站刀疤網(wǎng)也可能是她使用的某個銀行的官網(wǎng)的虛假版本,一個釣魚網(wǎng)站,它要求她以某種借口向銀行或社交網(wǎng)絡(luò)進行身份驗證。這個釣魚網(wǎng)站將數(shù)據(jù)返回給刀疤哥的方法有很多,例如將竊取到的數(shù)據(jù)放到刀疤網(wǎng)的 URL 后面,發(fā)送 GET 請求,這樣刀疤網(wǎng)的服務(wù)器就能拿到數(shù)據(jù)。跨站腳本攻擊可能有很多變種,這里說的是最一般的思路。
跨域資源共享(CORS)
瀏覽器開發(fā)者的第一個沖動可能就是完全阻止 JavaScript 程序進行任何互聯(lián)網(wǎng)訪問,這樣它們就沒法偷偷上傳用戶數(shù)據(jù)了。但顯然互聯(lián)網(wǎng)應(yīng)用的開發(fā)者需要他們的 JS 程序能夠進行網(wǎng)絡(luò)訪問。例如,銀行肯定需要加載一個程序,通過上傳用戶輸入的信息來向銀行服務(wù)器請求關(guān)于這個用戶的不同日期、不同賬戶的更多數(shù)據(jù)。顯然必須允許上傳數(shù)據(jù)才能實現(xiàn)這些交互。
但我們不會允許這些數(shù)據(jù)被上傳到刀疤哥的服務(wù)器上,這就是同源策略,只要程序在同一個互聯(lián)網(wǎng)域名(如 http://www.icbc.com.cn )的網(wǎng)頁中運行,那么程序可以與這個域名下的任何服務(wù)器地址進行交互。URI 中的協(xié)議和域名就組成了我們所說的「源」。因此同源政策(Same Origin Policy, SOP)說的就是,來自某一個服務(wù)器「源」的數(shù)據(jù),以及來自這個服務(wù)器的程序的數(shù)據(jù),都與來自任何其他源的任何數(shù)據(jù)分開。這使銀行的程序能夠很好地運作,又不會暴露隱私。
有什么場景之中同源策略是不可用嗎?這還是有的,任何需要程序去訪問其他域名下的數(shù)據(jù)的場景都會被同源策略阻礙到。例如如果有一個網(wǎng)站想提供一個 JavaScript 程序來檢驗、測試或者僅僅訪問另一個網(wǎng)頁,那么它沒法訪問那個網(wǎng)站上的數(shù)據(jù),也就沒法達到它的設(shè)計目的。
另一個例子是數(shù)據(jù)融合。當(dāng)政府開始公開大量的開放公共數(shù)據(jù)時,有一些網(wǎng)站會開始涌現(xiàn),這些網(wǎng)站從許多不同的開放數(shù)據(jù)站點加載數(shù)據(jù)并提供數(shù)據(jù)的「融合」—— 組合許多不同來源的數(shù)據(jù),并提供可視化,從而讓用戶享受到單一數(shù)據(jù)源所不能提供的洞察力。但實際上,典型的純前端的數(shù)據(jù)融合網(wǎng)站現(xiàn)在已經(jīng)無法工作了。
那么有什么替代方案?瀏覽器制造商實現(xiàn)了一些鉤子以允許數(shù)據(jù)在不同的源上共享,并稱之為跨域資源共享(CORS)。核心問題是 —— 如何在瀏覽器中區(qū)分?jǐn)?shù)據(jù),區(qū)分私人的網(wǎng)絡(luò)攝像頭數(shù)據(jù)和公開的政府開放數(shù)據(jù)?我們無法改變網(wǎng)絡(luò)攝像頭,但我們可以改變開放數(shù)據(jù)發(fā)布者。瀏覽器制造商現(xiàn)在要求開放數(shù)據(jù)發(fā)布者在 HTTP 響應(yīng)中為任何完全開放的數(shù)據(jù)添加特殊的 CORS 頭:
Access-control-allow-Origin: *
同時他們添加了一項功能,允許數(shù)據(jù)發(fā)布者指定有限的其他可信來源,這些來源將被允許訪問數(shù)據(jù)發(fā)布者產(chǎn)生的數(shù)據(jù)。
例如銀行可以允許可信的信用卡公司的程序訪問銀行的源下的用戶數(shù)據(jù),這可以使得運營銀行變得更容易:
Access-control-allow-Origin: credit card company.example.com
這意味著發(fā)布公開數(shù)據(jù)的人需要在他們的任何 HTTP 響應(yīng)里加上
Access-control-allow-Origin: *
這意味著給互聯(lián)網(wǎng)上隨機的開放數(shù)據(jù)者帶來大量的工作量,可能這些開放數(shù)據(jù)提供方會因為各種原因沒法給所有響應(yīng)都加上這一條響應(yīng)頭。這使得他們的數(shù)據(jù)只能被瀏覽器直接訪問,而沒法被互聯(lián)網(wǎng)應(yīng)用利用。
瀏覽器事實上不直接查看這些響應(yīng)頭,而是在一個前驅(qū)(Pre-flight)的 OPTIONS 請求里查看,這個請求會自動插入到其他主要的請求之前。所以當(dāng)開發(fā)者在開發(fā)者工具里看到主要的請求時,其實已經(jīng)有幾輪請求發(fā)生了。
響應(yīng)頭攔截
除了阻止訪問數(shù)據(jù)以外,CORS 系統(tǒng)還會阻止不同源的服務(wù)器的響應(yīng)頭發(fā)送給互聯(lián)網(wǎng)應(yīng)用。如果不想被阻止,服務(wù)器必須加上另一個響應(yīng)頭:
Access-Control-Allow-Headers: Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate上述的響應(yīng)頭里必須包含一些東西,比如「Link」。這些是一般會被瀏覽器阻止的響應(yīng)頭,你也可以把任何其他的應(yīng)用和服務(wù)器需要因其他目的而是用的響應(yīng)頭加進去。
HTTP 方法攔截
作為習(xí)題留給讀者思考。
例子
官方的示例 SoLiD 服務(wù)器通過這種方式來允許跨域資源訪問。
對 CORS 的調(diào)整
這里我們要說的調(diào)整是:CORS 的設(shè)計者事實上故意使其變得更加難使用。
有人會一種感覺,我明白,就是如果允許數(shù)據(jù)發(fā)布者簡單地把 ACAO:* 加在在他們發(fā)布的內(nèi)容上,這會是一個,讓 用戶很容易自廢武功的設(shè)計。
這里的「用戶」當(dāng)然不是普通用戶,而是指配置網(wǎng)絡(luò)服務(wù)器的系統(tǒng)管理員。我們擔(dān)心的是系統(tǒng)管理員會發(fā)現(xiàn)瀏覽器封鎖了對其數(shù)據(jù)的訪問,為了解決這個問題,他們只會在任何地方都加上這個響應(yīng)頭,即使某些數(shù)據(jù)實際上并不應(yīng)該被公開。例如,他們提供了不同版本的頁面給不同的用戶,此時保持用戶之間的數(shù)據(jù)隔離是很重要的,但他們會受到誘惑用ACAO:*來把所有數(shù)據(jù)都標(biāo)識成可以訪問的。
因此,只要傳入的請求中帶有用戶憑據(jù)信息,瀏覽器就會阻止服務(wù)器使用 ACAO:*。 每當(dāng)用戶「登錄」時,如果你愿意,明確地使用他們登錄時上傳的身份信息。
但是,有時系統(tǒng)需要訪問來自另一個源的用戶私有的信息,例如前述的銀行的例子。對于這種使用憑據(jù)的情況,只允許訪問 Access-Control-Allow-Origin 響應(yīng)頭中明確指定了的源。
麻煩的是 HTTPS:互聯(lián)網(wǎng)現(xiàn)在分為兩個網(wǎng)絡(luò),一個是我們用來登錄和傳遞憑證的網(wǎng)絡(luò),而另一個是低安全性的網(wǎng)絡(luò)。問題在于,如果你開發(fā)的不是最終用戶頂級應(yīng)用程序,而是一個中間件,一個代碼庫,你只能調(diào)用瀏覽器來做網(wǎng)絡(luò)操作,以及用于處理密碼或 TLS 的瀏覽器 API,必要時得進行登錄。中間件的代碼沒法知道整個過程。
這意味著,如果你的服務(wù)器發(fā)布的是完全公開的數(shù)據(jù),例如政府開放數(shù)據(jù),你希望任何代碼都能夠訪問你的數(shù)據(jù),系統(tǒng)管理員之間的經(jīng)驗法則是你應(yīng)該總是回應(yīng)任何請求頭相同的源。相比于用這個:
Access-control-allow-origin: *
所有的開放數(shù)據(jù)服務(wù)器應(yīng)該發(fā)送這個:
Access-control-allow-origin: $(RECEIVED_ORIGIN)
此處的 $(RECEIVED_ORIGIN) 就用請求頭中的源來替換。
這可能會比向所有公共數(shù)據(jù)服務(wù)器添加固定字段更復(fù)雜。不過這是一個進步,可以讓代碼來干活,而不是簡單地讓人去改改配置 —— 這需要讓每一個開放數(shù)據(jù)發(fā)布者都配合。
要向數(shù)據(jù)發(fā)布者解釋清楚這些東西簡直像打一場戰(zhàn)役,戰(zhàn)役的結(jié)果是到處都可以搜到這些代碼片段。事實上 Apache 都把這個搞成了一個內(nèi)部的環(huán)境變量[@@ref]
難道沒有更好的設(shè)計來設(shè)置靜態(tài)標(biāo)頭嗎,例如
Access-control-allow-origin: PUBLIC_AND_UNCUSTOMIZED
這樣系統(tǒng)管理員就不會把一些私密的東西隨便暴露出去了,或者也可以用用戶的身份來定制化?可能有的人會這么想。不過這就是現(xiàn)在 CORS 實現(xiàn)的方式。
所以,世界上的數(shù)據(jù)發(fā)布者都開始把 CORS 源配置通過反射添加到響應(yīng)頭里了。
但一旦你要使用反射式加源的響應(yīng)頭,很關(guān)鍵的一點事允許把 Origin 加到 Vary: 響應(yīng)頭里,如果你有 Vary: 的話。如果沒有,就加上一個:
Vary: Origin到每一個通過反射來配置 ACAO 的響應(yīng)頭里。
不然的話,這里有一個失敗的例子:
站點A上的程序向服務(wù)器請求公共開放數(shù)據(jù)
服務(wù)器使用 ACAO 響應(yīng)頭頭響應(yīng)數(shù)據(jù)
瀏覽器緩存該響應(yīng)
用戶使用站點B上的其他程序查看相同的數(shù)據(jù)
瀏覽器使用緩存副本,但其上的源A與請求站點B不匹配。
瀏覽器以靜默方式阻止了請求,用戶和開發(fā)者感到十分費解
因此,在正常運行的基于 CORS 的系統(tǒng)中,服務(wù)器發(fā)送 Vary:Origin 響應(yīng)頭,并強制瀏覽器為每個請求它的 Web 應(yīng)用程序保留不同的數(shù)據(jù)副本,這非常具有諷刺意味,因為這些數(shù)據(jù)副本可能是完全公開的數(shù)據(jù),不需要做任何隱私考量。
CORS設(shè)計在歷史上大多數(shù)時候都是整個網(wǎng)絡(luò)中最糟糕的設(shè)計。但現(xiàn)在,那些設(shè)計像 Solid 這樣的系統(tǒng)的人必須創(chuàng)建一個不受 XSS 攻擊影響的系統(tǒng),數(shù)據(jù)將是在用戶的控制下完全對外公開或者完全對外不可見,并且 Web 應(yīng)用程序?qū)⒈桓鞣N不同的社交流程視為可信賴的。
后記: 對CORS的第二次調(diào)整
對CORS的第二次調(diào)整是在瀏覽器還沒完全實現(xiàn)完第一次調(diào)整的時候發(fā)生的。
Notwithstanding issues with the design of CORS, Chrome doesn’t in fact do it properly.
If you request the same resource first from one origin and then from another, it serves the cached version, which then fails cord because the Origin and access-control-allow-origin headers don’t match. This even when the returned headers have “Vary: Origin”, which should prevent that same cached version being reused for a different origin.
盡管 CORS 的設(shè)計存在問題,但 Chrome 實際上也并沒有正確地實現(xiàn)它。如果你首先從一個源請求相同的資源,然后從另一個源請求相同的資源,瀏覽器將嘗試提供緩存版本,然后由于 Origin 和access-control-allow-origin 響應(yīng)頭不匹配而失敗。即使返回的響應(yīng)頭具有 Vary:Origin,這也應(yīng)該防止相同的緩存版本被重用于不同的源。
問題出現(xiàn)于 Chrome Version 59.0.3071.115 (Official Build) (64-bit)
火狐在 2018-07 也出了同樣的問題。