同源策略
1995年,同源政策由 Netscape 公司引入瀏覽器。目前,所有瀏覽器都實行這個政策。
最初,它的含義是指,A 網頁設定的 Cookie,B 網頁不能開啟,除非這兩個網頁“同源”。所謂“同源”指的是“三個相同”:
- 協議相同
- 域名相同
- 埠相同
同源政策的目的,是為了保證使用者資訊的安全,防止惡意的網站竊取資料。
設想這樣一種情況:A 網站是一家銀行,使用者登入以後,A 網站在使用者的機器上設定了一個 Cookie,包含了一些隱私資訊(比如存款總額)。使用者離開 A 網站以後,又去訪問 B 網站,如果沒有同源限制,B 網站可以讀取 A 網站的 Cookie,那麼隱私資訊就會洩漏。更可怕的是,Cookie 往往用來儲存使用者的登入狀態,如果使用者沒有退出登入,其他網站就可以冒充使用者,為所欲為。因為瀏覽器同時還規定,提交表單不受同源政策的限制。
由此可見,同源政策是必需的,否則 Cookie 可以共享,網際網路就毫無安全可言了。
隨著網際網路的發展,同源政策越來越嚴格。目前,如果非同源,共有三種行為受到限制。
- 無法獲取非同源網頁的 cookie、localstorage 和 indexedDB。
- 無法訪問非同源網頁的 DOM (iframe)。
- 無法向非同源地址傳送 AJAX 請求 或 fetch 請求(可以傳送,但瀏覽器拒絕接受響應)。
Ajax 跨域
瀏覽器的同源策略會導致跨域,也就是說,如果協議、域名或者埠有一個不同,都被當作是不同的域,就不能使用 Ajax 向不同源的伺服器傳送 HTTP 請求。首先我們要明確一個問題,請求跨域了,請求到底發出去沒有?答案是肯定發出去了,但是瀏覽器攔截了響應。
為什麼要有跨域
Ajax 的同源策略主要是為了防止 CSRF
(跨站請求偽造) 攻擊,如果沒有 AJAX 同源策略,相當危險,我們發起的每一次 HTTP 請求都會帶上請求地址對應的 cookie,那麼可以做如下攻擊:
- 使用者登入了自己的銀行頁面 mybank.com,mybank.com向使用者的cookie中新增使用者標識。
- 使用者瀏覽了惡意頁面 evil.com。執行了頁面中的惡意AJAX請求程式碼。
- evil.com向http://mybank.com發起AJAX HTTP請求,請求會預設把http://mybank.com對應cookie也同時傳送過去。
- 銀行頁面從傳送的cookie中提取使用者標識,驗證使用者無誤,response中返回請求資料。此時資料就洩露了。
- 而且由於Ajax在後臺執行,使用者無法感知這一過程。
DOM同源策略也一樣,如果 iframe
之間可以跨域訪問,可以這樣攻擊:
- 做一個假網站,裡面用iframe巢狀一個銀行網站 mybank.com。
- 把iframe寬高啥的調整到頁面全部,這樣使用者進來除了域名,別的部分和銀行的網站沒有任何差別。
- 這時如果使用者輸入賬號密碼,我們的主網站可以跨域訪問到http://mybank.com的dom節點,就可以拿到使用者的輸入了,那麼就完成了一次攻擊。
所以說有了跨域跨域限制之後,我們才能更安全的上網了。
跨域的解決方式
CORS
CORS 是一個 W3C 標準,全稱是跨域資源共享(Cross-origin resource sharing),它允許瀏覽器向跨源伺服器,發出XMLHttpRequest請求。
整個 CORS 通訊過程,都是瀏覽器自動完成,不需要使用者參與。對於開發者來說,CORS 通訊與普通的 AJAX 通訊沒有差別,程式碼完全一樣。瀏覽器一旦發現 AJAX 請求跨域,就會自動新增一些附加的頭資訊,有時還會多出一次附加的請求,但使用者不會有感知。因此,實現 CORS 通訊的關鍵是伺服器。只要伺服器實現了 CORS 介面,就可以跨域通訊。
伺服器端配置
CORS常用的配置項有以下幾個:
-
Access-Control-Allow-Origin(必含) – 允許的域名,只能填
*
(萬用字元)或者單域名。 -
Access-Control-Allow-Methods(必含) – 這允許跨域請求的 http 方法(常見有
POST、GET、OPTIONS
)。 -
Access-Control-Allow-Headers(當預請求中包含
Access-Control-Request-Headers
時必須包含) – 這是對預請求當中Access-Control-Request-Headers
的回覆,和上面一樣是以逗號分隔的列表,可以返回所有支援的頭部。 -
Access-Control-Allow-Credentials(可選) – 表示是否允許傳送Cookie,只有一個可選值:true(必為小寫)。如果不包含cookies,請略去該項,而不是填寫false。這一項與 XmlHttpRequest 物件當中的
withCredentials
屬性應保持一致,即 withCredentials 為true時該項也為true;withCredentials 為false時,省略該項不寫。反之則導致請求失敗。 -
Access-Control-Max-Age(可選) – 以秒為單位的快取時間。在有效時間內,瀏覽器無須為同一請求再次發起預檢請求。
CORS 跨域的判定流程
-
瀏覽器先根據同源策略對前端頁面和後臺互動地址做匹配,若同源,則直接傳送資料請求;若不同源,則傳送跨域請求。
-
伺服器收到瀏覽器跨域請求後,根據自身配置返回對應檔案頭。若未配置過任何允許跨域,則檔案頭裡不包含
Access-Control-Allow-origin
欄位,若配置過域名,則返回Access-Control-Allow-origin + 對應配置規則裡的域名的方式
。 -
瀏覽器根據接受到的 響應頭裡的
Access-Control-Allow-origin
欄位做匹配,若無該欄位,說明不允許跨域,從而丟擲一個錯誤;若有該欄位,則對欄位內容和當前域名做比對,如果同源,則說明可以跨域,瀏覽器接受該響應;若不同源,則說明該域名不可跨域,瀏覽器不接受該響應,並丟擲一個錯誤。
上面說到的兩種型別的報錯,控制檯輸出是不一樣的:
- 伺服器允許跨域請求,但是 Origin 指定的源,不在許可範圍內,伺服器會返回一個正常的HTTP迴應。瀏覽器發現,這個迴應的頭資訊沒有包含
Access-Control-Allow-Origin
欄位,就知道出錯了,從而丟擲一個錯誤,被 XMLHttpRequest的onerror 回撥函式捕獲。注意,這種錯誤無法通過狀態碼識別,因為 HTTP 迴應的狀態碼有可能是200。
<!--控制檯返回結果-->
XMLHttpRequest cannot load http://localhost/city.json.
The 'Access-Control-Allow-Origin' header has a value 'http://segmentfault.com' that is not equal to the supplied origin.
Origin 'http://www.zhihu.com' is therefore notallowed access.
複製程式碼
- 伺服器不允許任何跨域請求
<!--控制檯返回結果-->
XMLHttpRequest cannot load http://localhost/city.json.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'http://www.zhihu.com' is therefore not allowed access.
複製程式碼
簡單請求
實際上瀏覽器將CORS請求分成兩類:簡單請求(simple request
)和非簡單請求(not-so-simple request
)。
簡單請求是指滿足以下條件的(一般只考慮前面兩個條件即可):
- 使用
GET、POST、HEAD
其中一種請求方法。 - HTTP的頭資訊不超出以下幾種欄位:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限於三個值
application/x-www-form-urlencoded、multipart/form-data、text/plain
- 請求中的任意XMLHttpRequestUpload 物件均沒有註冊任何事件監聽器;
- XMLHttpRequestUpload 物件可以使用 XMLHttpRequest.upload 屬性訪問。 請求中沒有使用 ReadableStream 物件。
對於簡單請求,瀏覽器直接發起 CORS 請求,具體來說就是伺服器端會根據請求頭資訊中的 origin
欄位(包括了協議 + 域名 + 埠),來決定是否同意這次請求。
如果 origin
指定的源在許可範圍內,伺服器返回的響應,會多出幾個頭資訊欄位:
Access-Control-Allow-Origin: http://xxx.xxx.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
複製程式碼
非簡單請求
非簡單請求時指那些對伺服器有特殊要求的請求,比如請求方法是 put
或 delete
,或者 content-type
的型別是 application/json
。其實簡單請求之外的都是非簡單請求了。
非簡單請求的 CORS 請求,會在正式通訊之前,使用 OPTIONS
方法發起一個預檢(preflight)請求到伺服器,瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些 HTTP 動詞和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的 XMLHttpRequest 請求,否則就報錯。
下面是一個預檢請求的頭部:
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
複製程式碼
一旦伺服器通過了"預檢"請求,以後每次瀏覽器正常的CORS請求,就都跟簡單請求一樣了。
關於為什麼要有簡單請求和非簡單請求,可參考知乎上的一個回答 為什麼跨域的post請求區分為簡單請求和非簡單請求和content-type相關?
參考資料
JSONP
JSONP 的原理就是利用 <script>
標籤的 src 屬性沒有跨域的限制,通過指向一個需要訪問的地址,由服務端返回一個預先定義好的 Javascript 函式的呼叫,並且將伺服器資料以該函式引數的形式傳遞過來,此方法需要前後端配合完成。
//定義獲取資料的回撥方法
function getData(data) {
console.log(data);
}
// 建立一個script標籤,並且告訴後端回撥函式名叫 getData
var body = document.getElementsByTagName('body')[0];
var script = document.gerElement('script');
script.type = 'text/javasctipt';
script.src = 'demo.js?callback=getData';
body.appendChild(script);
//script 載入完畢之後從頁面中刪除,否則每次點選生成許多script標籤
script.onload = function () {
document.body.removeChild(script);
}
複製程式碼
JSONP 使用簡單且相容性不錯,但是隻限於 get
請求。
伺服器代理
瀏覽器有跨域限制,但是伺服器不存在跨域問題,所以可以由伺服器請求所要域的資源再返回給客戶端。
一般我們在本地環境開發時,就是使用 webpack-dev-server
在本地開啟一個服務進行代理訪問的。
document.domain
該方式只能用於二級域名相同的情況下,比如 a.test.com
和 b.test.com
適用於該方式。
只需要給兩個頁面都新增 document.domain = 'test.com'
,通過在 a.test.com
建立一個 iframe
,去控制 iframe
的 window
,從而進行互動。
postMessage
window.postMessage 是一個 HTML5 的 api,允許兩個視窗之間進行跨域傳送訊息。
這種方式通常用於獲取嵌入頁面中的第三方頁面資料。一個頁面傳送訊息,另一個頁面判斷來源並接收訊息
// 傳送訊息端
var receiver = document.getElementById('receiver').contentWindow;
var btn = document.getElementById('send');
btn.addEventListener('click', function (e) {
e.preventDefault();
var val = document.getElementById('text').value;
receiver.postMessage("Hello "+val+"!", "http://res.42du.cn");
});
// 接收訊息端
window.addEventListener("message", receiveMessage, false);
function receiveMessage(event){
if (event.origin !== "http://www.42du.cn")
return;
}
複製程式碼
詳情可參考 MDN | window.postMessage
還有一些方法,比如window.name和location.hash。都比較適用於 iframe 的跨域,不過 iframe 用的比較少了,所以這些方法也就有點過時了。