徹底理解瀏覽器的跨域

Escape Plan發表於2019-04-10

同源策略

1995年,同源政策由 Netscape 公司引入瀏覽器。目前,所有瀏覽器都實行這個政策。

最初,它的含義是指,A 網頁設定的 Cookie,B 網頁不能開啟,除非這兩個網頁“同源”。所謂“同源”指的是“三個相同”:

  • 協議相同
  • 域名相同
  • 埠相同

同源政策的目的,是為了保證使用者資訊的安全,防止惡意的網站竊取資料。

設想這樣一種情況:A 網站是一家銀行,使用者登入以後,A 網站在使用者的機器上設定了一個 Cookie,包含了一些隱私資訊(比如存款總額)。使用者離開 A 網站以後,又去訪問 B 網站,如果沒有同源限制,B 網站可以讀取 A 網站的 Cookie,那麼隱私資訊就會洩漏。更可怕的是,Cookie 往往用來儲存使用者的登入狀態,如果使用者沒有退出登入,其他網站就可以冒充使用者,為所欲為。因為瀏覽器同時還規定,提交表單不受同源政策的限制。

由此可見,同源政策是必需的,否則 Cookie 可以共享,網際網路就毫無安全可言了。

隨著網際網路的發展,同源政策越來越嚴格。目前,如果非同源,共有三種行為受到限制。

  1. 無法獲取非同源網頁的 cookie、localstorage 和 indexedDB。
  2. 無法訪問非同源網頁的 DOM (iframe)。
  3. 無法向非同源地址傳送 AJAX 請求 或 fetch 請求(可以傳送,但瀏覽器拒絕接受響應)。

Ajax 跨域

瀏覽器的同源策略會導致跨域,也就是說,如果協議、域名或者埠有一個不同,都被當作是不同的域,就不能使用 Ajax 向不同源的伺服器傳送 HTTP 請求。首先我們要明確一個問題,請求跨域了,請求到底發出去沒有?答案是肯定發出去了,但是瀏覽器攔截了響應。

為什麼要有跨域

Ajax 的同源策略主要是為了防止 CSRF(跨站請求偽造) 攻擊,如果沒有 AJAX 同源策略,相當危險,我們發起的每一次 HTTP 請求都會帶上請求地址對應的 cookie,那麼可以做如下攻擊:

  1. 使用者登入了自己的銀行頁面 mybank.commybank.com向使用者的cookie中新增使用者標識
  2. 使用者瀏覽了惡意頁面 evil.com。執行了頁面中的惡意AJAX請求程式碼。
  3. evil.com向http://mybank.com發起AJAX HTTP請求,請求會預設把http://mybank.com對應cookie也同時傳送過去。
  4. 銀行頁面從傳送的cookie中提取使用者標識,驗證使用者無誤,response中返回請求資料。此時資料就洩露了。
  5. 而且由於Ajax在後臺執行,使用者無法感知這一過程。

DOM同源策略也一樣,如果 iframe 之間可以跨域訪問,可以這樣攻擊:

  1. 做一個假網站,裡面用iframe巢狀一個銀行網站 mybank.com
  2. 把iframe寬高啥的調整到頁面全部,這樣使用者進來除了域名,別的部分和銀行的網站沒有任何差別。
  3. 這時如果使用者輸入賬號密碼,我們的主網站可以跨域訪問到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 跨域的判定流程
  1. 瀏覽器先根據同源策略對前端頁面和後臺互動地址做匹配,若同源,則直接傳送資料請求;若不同源,則傳送跨域請求。

  2. 伺服器收到瀏覽器跨域請求後,根據自身配置返回對應檔案頭。若未配置過任何允許跨域,則檔案頭裡不包含 Access-Control-Allow-origin 欄位,若配置過域名,則返回 Access-Control-Allow-origin + 對應配置規則裡的域名的方式

  3. 瀏覽器根據接受到的 響應頭裡的 Access-Control-Allow-origin 欄位做匹配,若無該欄位,說明不允許跨域,從而丟擲一個錯誤;若有該欄位,則對欄位內容和當前域名做比對,如果同源,則說明可以跨域,瀏覽器接受該響應;若不同源,則說明該域名不可跨域,瀏覽器不接受該響應,並丟擲一個錯誤。

上面說到的兩種型別的報錯,控制檯輸出是不一樣的:

  1. 伺服器允許跨域請求,但是 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.
複製程式碼
  1. 伺服器不允許任何跨域請求
<!--控制檯返回結果-->
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)。

簡單請求是指滿足以下條件的(一般只考慮前面兩個條件即可):

  1. 使用 GET、POST、HEAD 其中一種請求方法。
  2. HTTP的頭資訊不超出以下幾種欄位:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type:只限於三個值 application/x-www-form-urlencoded、multipart/form-data、text/plain
  3. 請求中的任意XMLHttpRequestUpload 物件均沒有註冊任何事件監聽器;
  4. 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
複製程式碼
非簡單請求

非簡單請求時指那些對伺服器有特殊要求的請求,比如請求方法是 putdelete,或者 content-type 的型別是 application/json。其實簡單請求之外的都是非簡單請求了。

非簡單請求的 CORS 請求,會在正式通訊之前,使用 OPTIONS 方法發起一個預檢(preflight)請求到伺服器,瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些 HTTP 動詞和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的 XMLHttpRequest 請求,否則就報錯。

ba91e7eb58b5d0d2d7ff2f66f0e83918.png

下面是一個預檢請求的頭部:

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相關?

參考資料

跨域資源共享 CORS 詳解

跨域的那些事兒

MDN HTTP訪問控制(CORS)

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.comb.test.com 適用於該方式。

只需要給兩個頁面都新增 document.domain = 'test.com',通過在 a.test.com 建立一個 iframe,去控制 iframewindow,從而進行互動。

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 用的比較少了,所以這些方法也就有點過時了。

相關文章