AJAX 與跨域通訊(二):跨域解決方案

  • 2019 年 11 月 11 日
  • 筆記

本篇講解常見的幾種跨域方案:JSONPCORS、影像Ping、document.domainwindow.name

開始之前,要先清楚一件事:

跨域不一定是瀏覽器限制了發起跨站請求,而也可能是跨站請求可以正常發起,但是返回結果被瀏覽器攔截了。最好的例子是 CSRF 跨站攻擊原理,請求是發送到了後端伺服器無論是否跨域!注意:有些瀏覽器不允許從 HTTPS 的域跨域訪問 HTTP,比如 Chrome 和 Firefox,這些瀏覽器在請求還未發出的時候就會攔截請求,這是一個特例。

1. JSONP

<link> 獲取 CSS,<script> 獲取 JS,<img> 獲取圖片,這些明明也是跨域獲取資源,為什麼不會被禁止呢?很簡單,因為這些都不屬於上述特定操作之一,這裡請求資源壓根沒用到 AJAX 請求。再看看我們的需求,我們現在是要在 A 域中獲取 B 域資源,那麼我完全可以在 A 域中動態創建一個 script 並請求 B 域資源,然後,因為 A 域中的 js 和 scirpt 中的 js 是在同一個作用域中的,所以要在 A 域中展示 B 域的數據也完全不成問題。雖然說法比較簡陋,但這就是 JSONP 的原理。下面我們來看看具體實現:

// 1.回調函數  function handleResponse(data){      console.log(data);  }  // 2.動態創建 script  var script = document.createElement('script');  script.src = 'http://test.com/json?callback=handleResponse';  document.body.insertBefore(script,document.body.firstChild);

首先是客戶端的角度,這段程式碼聲明了一個用以接受數據的回調函數,之後動態創建了 script ,執行完畢之後來到 body,這時候遇到語句 <script src='http://test.com/json?callback=handleResponse'></script>,此時會向伺服器發起一次資源請求;然後來到服務端的角度,服務端解析上述的 url,得到查詢參數 callback 的值是 handleResponse,此時會生成一個對應的函數執行語句,也就是 handleResponse(data),這個語句返回給了客戶端這邊,客戶端執行該語句(因為當前作用域確實聲明了這個 handleResponse 函數),列印相關數據。這樣就算完成一次跨域請求了。

JSONP 使用起來雖然很簡單,但是有如下缺點:

  • 無法發送 POST 請求
  • 安全問題。萬一服務端那邊夾帶惡意程式碼返回過來,那麼客戶端這邊是會直接執行的,因此有安全隱患
  • 無法監測 JSONP 請求是否成功或失敗

2. CORS

CORS 即 Cross-origin resource sharing,跨域資源共享 ,是由 W3C 官方推廣的允許通過 AJAX 技術跨域獲取資源的規範 。

CORS 的關鍵在於服務端,也就是客戶端這邊發送請求,服務端那邊做一些判斷(請求方是否在自己的「白名單」里?),如果沒問題就返回數據,否則拒絕。

瀏覽器將 CORS 請求分成兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。

只要同時滿足以下兩大條件,就屬於簡單請求:

  • 請求方法只屬於 HEADGETPOST 請求的其中一種;
  • HTTP的頭資訊只限於以下欄位:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type(只能為 application/x-www-form-urlencodedmultipart/form-datatext/plain 其中一種)

凡不同時滿足以上兩大條件的,都屬於非簡單請求。

下面我們看一下針對這兩種請求,CORS 是怎麼處理。

2.1 簡單請求

首先是客戶端的角度,發送請求時瀏覽器檢測到這是一個簡單請求,因此在請求頭額外增加一個 Origin,它的值是請求程式碼所在的源,例如 http://test.com

GET /cors HTTP/1.1  Origin: http://test.com  Host: target.com  Accept-Language: en-US  Connection: keep-alive  User-Agent: Mozilla/5.0 ...

然後是服務端的角度,服務端收到請求,首先檢測請求報頭的 Origin 是否在自己的許可範圍內,

如果確實是許可的域,那麼待會響應的時候,響應頭會額外增加如下欄位:

  • Access-Control-Allow-Origin(必選) :這個欄位用來告知客戶端,服務端能夠接受的發送 AJAX 請求的域,因為此次請求得到許可,所以這裡返回與先前請求報頭中 Origin 匹配的 http://test.com。當然,也可以返回 *,表示接受任何域的 AJAX 請求(* 是通配的意思)。
  • Access-Control-Allow-Credentials (可選):告知瀏覽器,是否允許客戶端發送請求的時候攜帶 Cookie,true 表示允許,false 表示禁止,出於安全問題考慮(前面說過),CORS 默認不允許跨域 AJAX 請求攜帶 Cookie。
  • Access-Control-Expose-Headers(可選):該欄位用來向客戶端暴露可獲取的響應頭。默認情況下,xhr 的 getResponseHeader() 方法只能拿到 6 個基本響應頭欄位,如果還想額外拿到其它欄位,那麼前端要和後端商量好,讓後端在 Access-Control-Expose-Headers 指定好前端可以通過該方法獲取的額外響應頭欄位。

如果不是許可的域,那麼這時候其實壓根不會返回 Access-Control-Allow-Origin 這個響應頭,而瀏覽器會捕獲這次錯誤,如下圖所示:

PS:雖然禁止跨域 AJAX 請求攜帶 Cookie 是為了安全考慮,但由於它在身份驗證中的重要性,我們有時候還是得攜帶 Cookie 的。 具體方法是:

  • 客戶端配置 withCredentials 屬性:
var xhr = new XMLHttpRequest()  xhr.withCredentials = true
  • 服務端配置 Access-Control-Allow-Credential 為 true,配置 Access-Control-Allow-Origin 為指定的域(而不是 *),

2.2 非簡單請求

非簡單請求包括兩次請求,第一次請求是 preflight request,也就是預檢/查詢請求,這次請求試探性地「詢問」服務端,自己打算進行的非簡單請求是否合法 —— 不管是否合法,服務端都會通過某種方式通知客戶端,客戶端基於這個結果,判斷是否進行第二次真正的請求。

預檢請求是這樣的:

首先是客戶端的角度,發送請求時瀏覽器檢測到這是一個非簡單請求,所以事先向服務端發送一個預檢請求:

OPTIONS /cors HTTP/1.1  Origin: http://test.com  Access-Control-Request-Method: PUT  Access-Control-Request-Headers: Custom-Header1,Custom-Header2  Host: target.com  Accept-Language: en-US  Connection: keep-alive  User-Agent: Mozilla/5.0...
  • 注意,這裡這個預檢請求的類型是 OPTIONS
  • 像之前的簡單請求一樣,這裡瀏覽器會追加一個 Origin,表示請求程式碼所在的源
  • 前面我們說過,非簡單請求會多出額外的請求頭欄位,這裡多出來的就是 Access-Control-Request-MethodAccess-Control-Request-Headers ,這其實是告訴服務端,「我待會要進行的真正請求,類型是這裡 Access-Control-Request-Headers 指定的類型,然後自定義請求頭是這裡 Access-Control-Request-Headers 指定的值,你看看行不行,給我個回應「。

好了,我們來看看伺服器作何反應。來到服務端的角度,服務端收到這個請求,它會檢測請求頭中的資訊,發現這個請求是合法的、沒啥毛病,「好,我同意你的第二次請求」,不過光說不行,得在返回的響應頭中告訴客戶端這一點,此時響應頭是這樣的:

HTTP/1.1 200 OK  Date: Mon, 01 Dec 2008 01:15:39 GMT  Server: Apache/2.0.61(Unix)  Access-Control-Allow-Origin: http://test.com  Access-Control-Allow-Methods: GET, POST, PUT  Access-Control-Allow-Headers: Custom-Header1,Custom-Header2  Access-Control-Max-Age: 1728000  Content-type: text/html; charset=utf-8  Content-Encoding: gzip  Content-Length: 0  Keep-Alive: timeout=2, max=100  Connection: Keep-Alive  Content-Type: text/plain
  • Access-Control-Allow-Origin:這裡和之前一樣,可以是 http://test.com 或者 *,也就是告訴客戶端,「我給你的域下了許可證「
  • Access-Control-Allow-Methods:這裡告訴客戶端,服務端允許的跨域 AJAX 請求的類型,」雖然你剛才告訴我你準備進行的是 PUT 請求,不過你要進行 GET 或者 POST 請求,我也是允許的「
  • Access-Control-Allow-Headers:這裡告訴客戶端,服務端允許的發送請求時的自定義請求頭
  • Access-Control-Max-Age: 這裡告訴客戶端預檢請求的有效期,省去了多次的預檢請求。也就是說,」我給你開個後門,1728000 秒內(20天內)你可以直接發送真正的 AJAX 請求,不用每次都來問我了「

再回到客戶端這邊,客戶端收到響應,知道服務端允許了自己的請求,於是進行第二次真正的 AJAX 跨域請求。此後每次 CORS 請求都相當於一次簡單請求了。

但是,如果發現客戶端的請求是不合法的,那麼服務端雖然會返回正常響應,但不會返回 CORS 相關的響應頭,而客戶端這邊」心領神會「,知道被拒絕了,所以由 xhr 對象捕獲這個錯誤,如下圖所示:

我們可以來解讀一下這個報錯:上圖的 Response to preflight request 就是服務端對於預檢請求的響應,這個響應返回到客戶端之後,客戶端進行一次 access control check,也就是檢查這個響應是否有標誌著服務端同意的響應頭,因為 No 『Access-Control-Allow-Origin』 header is present on the requested resource,也就是說我客戶端這邊並沒有檢查到服務端本應提供的 Access-Control-Allow-Origin 響應頭,所以最終 doesn』t pass access control check,也就是沒有通過這次檢查。

3. 影像 Ping

  • 影像 Ping 是與伺服器進行簡單、單向的跨域通訊的一種方式,請求的數據是通過查詢字元串形式發送的,而響應可以是任意內容,通常是像素圖和 204 響應。瀏覽器雖然得不到任何具體數據,但由於可以監聽 load 和 error 事件,所以能知道響應是什麼時候接受到的。
  • 影像 Ping 最常用於跟蹤用戶點擊頁面或動態廣告曝光次數
  • 缺點:單向通訊,只支援 GET 請求;無法訪問伺服器的響應文本

4. document.domain

介紹 document.domain 跨域之前,先解釋一下域名的一些概念。

  • 頂級域名:諸如 .com、.cn、.net、.org 等都是頂級域名,也叫一級域名
  • 二級域名:諸如 baidu.com、zhihu.com、mdn.org 等
  • 父域名、子域名:這是相對的概念,諸如 .com 是 tool.com 的父域名,而 tool.com 的子域名是 editor.tool.com,editor.tool.com 的子域名是 www.editor.tool.com

document.domain 適用於主域相同、子域不同的兩個域之間的跨域通訊。假設我現在有一個A域為 http://www.test.com/a.html ,另一個B域為 http://test.com/b.html ,因為是不同源的(域名不相同),所以我不能在A域中拿到B域的東西,但是呢,我們注意到這兩個域的主域是相同的,只是子域不同而已,所以我們可以用 document.domain 的方法實現跨域,具體來說,就是重新設置兩個頁面的 document.domain 為一個相同的值。

但要注意的是,document.domain 的設置是有限制的,我們只能把 document.domain 設置成自身或更高一級的父域,且主域必須始終保持相同。例如:a.b.test.com 中某個文檔的 document.domain 可以設成a.b.test.com(自身)、b.test.com(上一級父域) 、test.com(上上一級父域)中的任意一個,但是不可以設成 c.a.b.test.com(下一級子域),因為這是當前域的子域,也不可以設成 baidu.com,因為主域已經不相同了,這裡的主域必須始終保持為 test.com 不變。

來看程式碼:

A域 http://www.test.com/a.html

<iframe src=" http://test.com/b.html" id="myIframe" onload="test()">  <script>      document.domain = 'test.com'; // 設置成主域      function test() {          console.log(document.getElementById('myIframe').contentWindow);      }  </script>

B域 http://test.com/b.html

<script>      document.domain = 'test.com'; // 雖然本來就是 test.com,但還是要顯式設置一次  </script>

之後,我們就可以在 A 域中拿到 B 域的東西了。注意,儘管這時候 document.domain 是一樣的,但兩個域之間只是可以交互而已,仍然不能發送 AJAX 請求。

5. window.name

首先要明白一件事 —— window 對象有個 name 屬性,在一個窗口的生命周期內,window.name 會被該窗口的所有頁面所共享、所讀寫,不管這些頁面是同源還是不同源。

那麼,我們豈不是可以把數據放在 window.name 里,然後通過頁面跳轉把這些數據拿到自己這邊來?有道理,不過每次要拿數據就得跳轉頁面,好像有點麻煩,不妨我們把這個頁面跳轉的過程放在 iframe 里進行。假定請求數據的頁面是 a.html,存放數據的頁面是 c.html,那麼我們在 a.html 中通過 iframe 載入 c.html,這時候數據已經存放在 iframe 這個窗口的 window.name 里了,之後我們讓其跳轉到與 a.html 同源的 b.html,根據前面說的,window.name 仍然是被保留的、可訪問的,那麼 window.name 由 c 傳遞到了 b,並且由於此時 a.html、b.html 同源,所以 window.name 又可經由 b 傳遞給 a。

下面說說程式碼實現:

// c.html  <script type="text/javascript">      window.name = 我是要傳遞的 json 數據;  </script>
// b.html  <body>      我只是一個中轉站  </body>
// a.html    <p>hello world</p>  <script>  var p = document.getElementsByTagName('p')[0];  var isFirst = true;  var iframe = document.createElement('iframe');    iframe.src = 'http://localhost:3001/c.html';  iframe.style.display = 'none';  document.body.appendChild(iframe);    //監聽 iframe 的兩次載入  iframe.onload = function () {      if(isFirst){          iframe.src = 'http://localhost:3000/b.html';          isFirst = false;      }else {          p.innerHTML = iframe.contentWindow.name;          // 銷毀iframe          iframe.contentWindow.close();          document.body.removeChild(iframe);          iframe.src = '';          iframe = null;      }  }  </script>  </body>  </html>

這裡動態創建了 iframe,並指定第一次載入的 iframe 是 c.html,一旦載入好(很顯然這時候 window.name 的值已經記錄在這個窗口裡了),就執行回調函數,通過修改 src 讓頁面跳轉到 b.html(這時候 window.name 的值傳遞給了 b.html),第二次觸發執行回調函數,將最初的數據傳遞給 a.html。

注意兩個地方:

  • 由於整個過程是悄悄進行的,我們給 iframe 設置 display:none
  • 拿到數據後記得銷毀 iframe,防止記憶體泄露

上面的寫法不需要重寫 onload 回調函數,只用一個 flag 標識第一和第二次載入;我們也可以採用下面的方法重寫 onload 回調:

iframe.onload = function () {      iframe.onload = function(){          p.innerHTML = iframe.contentWindow.name;          iframe.contentWindow.close();          document.body.removeChild(iframe);          iframe.src = '';          iframe = null;      }      iframe.src = 'http://localhost:3000/b.html';  }

參考:

《JavaScript 高級程式設計》第三版 再也不學AJAX了!(三)跨域獲取資源 ② – JSONP & CORS js 中幾種常用的跨域方法詳解 cross-domain github demo