1. 程式人生 > >跨域 CORS

跨域 CORS

跨域請求一直是網頁程式設計中的一個難題,在過去,絕大多數人都傾向於使用JSONP來解決這一問題。不過現在,我們可以考慮一下W3C中一項新的特性——CORS(Cross-Origin Resource Sharing)了。

客戶端

建立XmlHttpRequest物件

對於CORS,Chrome、FireFox以及Safari,需要使用XmlHttpRequest2物件;而對於IE,則需要使用XDomainRequest;Opera目前還不支援這一特性,但很快就會支援。

因此,在物件的建立上,我們不得不首先針對不同的瀏覽器而進行一下預處理:

function createCORSRequest(method, url) {
  var xhr = new XMLHttpRequest();
  if ("withCredentials" in xhr) {

    // "withCredentials"屬性是XMLHTTPRequest2中獨有的
    xhr.open(method, url, true);

  } else if (typeof XDomainRequest != "undefined") {

    // 檢測是否XDomainRequest可用
    xhr = new XDomainRequest();
    xhr.open(method, url);

  } else {

    // 看起來CORS根本不被支援
    xhr = null;

  }
  return xhr;
}

var xhr = createCORSRequest('GET', url);
if (!xhr) {
  throw new Error('CORS not supported');
}

事件處理

原先的XmlHttpRequest物件僅僅只有一個事件——onreadystatechange,用以通知所有的事件,而現在,我們除了這個事件之外又多了很多新的。

事件 說明
onloadstart* 當請求發生時觸發
onprogress 讀取及傳送資料時觸發
onabort* 當請求被中止時觸發,如使用abort()方法
onerror 當請求失敗時觸發
onload 當請求成功時觸發
ontimeout 當呼叫者設定的超時時間已過而仍未成功時觸發
onloadend* 請求結束時觸發(無論成功與否)

絕大多數情況下,我們只需要和onload

onerror打交道,就像下面這樣:

xhr.onload = function() {
 var responseText = xhr.responseText;
 console.log(responseText);
 // 繼續其它程式碼
};

xhr.onerror = function() {
  console.log('There was an error!');
};

這兒有一點小異樣。儘管我們可以通過onerror得知請求發生了錯誤,但在事件處理時,我們無法從程式碼上獲知失敗的任何原因。比如,FireFox在失敗時將responseText置空並返回一個0值作為狀態,這當中並不包含任何錯誤的具體情況。

withCredentials

標準的CORS請求不對cookies做任何事情,既不傳送也不改變。如果希望改變這一情況,就需要將withCredentials設定為true

xhr.withCredentials = true;

另外,服務端在處理這一請求時,也需要將Access-Control-Allow-Credentials設定為true。這一點我們稍後來說。

withCredentials屬性使得請求包含了遠端域的所有cookies,但值得注意的是,這些cookies仍舊遵守“同域”的準則,因此從程式碼上你並不能從document.cookies或者回應HTTP頭當中進行讀取。

傳送請求

請求通過一個簡單的send()方法進行傳送,如果請求當中需要包含任何內容,也只需要將其作為一個引數傳遞給send()即可。一旦服務端配置OK,那麼你將只需要處理後續的onload事件,這正像我們平時所熟悉的XHR一樣。

來看一段完整的小程式碼:

// 建立XHR物件
function createCORSRequest(method, url) {
  var xhr = new XMLHttpRequest();
  if ("withCredentials" in xhr) {
    // 針對Chrome/Safari/Firefox.
    xhr.open(method, url, true);
  } else if (typeof XDomainRequest != "undefined") {
    // 針對IE
    xhr = new XDomainRequest();
    xhr.open(method, url);
  } else {
    // 不支援CORS
    xhr = null;
  }
  return xhr;
}

// 輔助函式,用於解析返回的內容
function getTitle(text) {
  return text.match('')[1];
}

// 傳送CORS請求
function makeCorsRequest() {
  // bibliographica.org是支援CORS的
  var url = 'http://bibliographica.org/';

  var xhr = createCORSRequest('GET', url);
  if (!xhr) {
    alert('CORS not supported');
    return;
  }

  // 迴應處理
  xhr.onload = function() {
    var text = xhr.responseText;
    var title = getTitle(text);
    alert('Response from CORS request to ' + url + ': ' + title);
  };

  xhr.onerror = function() {
    alert('Woops, there was an error making the request.');
  };

  xhr.send();
}

服務端

一個CORS請求可能包含多個HTTP頭,甚至有多個請求實際傳送,這對於客戶端的開發者來說通常是透明的。因為瀏覽器已經負責實現了CORS最關鍵的部分;但是服務端的後臺指令碼則需要我們自己進行處理,因此我們還需要了解到服務端到底從瀏覽器那裡收到了怎樣的內容。

先來看看流程圖吧。

CORS流程圖

CORS分類

CORS可以分成兩種:

  • 簡單請求
  • 複雜請求

一個簡單的請求大致如下:

  • HTTP方法是下列之一
    • HEAD
    • GET
    • POST
  • HTTP頭包含
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type,但僅能是下列之一
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

任何一個不滿足上述要求的請求,即被認為是複雜請求。一個複雜請求不僅有包含通訊內容的請求,同時也包含預請求(preflight request)。

簡單請求

為了搞清楚複雜請求與簡單請求有何區別,我們首先來看看簡單請求是怎樣處理的。

JavaScript:

var url = 'http://alice.com/cors';
var xhr = createCORSRequest('GET', url);
xhr.send();

HTTP請求:

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

簡單請求的傳送從程式碼上來看和普通的XHR沒太大區別,但是HTTP頭當中要求總是包含一個域(Origin)的資訊。該域包含協議名、地址以及一個可選的埠。不過這一項實際上由瀏覽器代為傳送,並不是開發者程式碼可以觸及到的。

HTTP迴應:

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

在迴應中,COR相關的專案全都是以“Access-Control-”作為字首的,其意義分列如下:

  • Access-Control-Allow-Origin(必含)- 不可省略,否則請求按失敗處理。該項控制資料的可見範圍,如果希望資料對任何人都可見,可以填寫“*”。
  • Access-Control-Allow-Credentials(可選) – 該項標誌著請求當中是否包含cookies資訊,只有一個可選值:true(必為小寫)。如果不包含cookies,請略去該項,而不是填寫false。這一項與XmlHttpRequest2物件當中的withCredentials屬性應保持一致,即withCredentialstrue時該項也為truewithCredentialsfalse時,省略該項不寫。反之則導致請求失敗。
  • Access-Control-Expose-Headers(可選) – 該項確定XmlHttpRequest2物件當中getResponseHeader()方法所能獲得的額外資訊。通常情況下,getResponseHeader()方法只能獲得如下的資訊:
    • Cache-Control
    • Content-Language
    • Content-Type
    • Expires
    • Last-Modified
    • Pragma

    當你需要訪問額外的資訊時,就需要在這一項當中填寫並以逗號進行分隔。不過目前瀏覽器對這一項的實現仍然有一些問題,具體請見文尾的BUG一節。

複雜請求

如果僅僅是簡單請求,那麼即便不用CORS也沒有什麼大不了,但CORS的複雜請求就令CORS顯得更加有用了。簡單來說,任何不滿足上述簡單請求要求的請求,都屬於複雜請求。比如說你需要傳送PUTDELETE等HTTP動作,或者傳送Content-Type: application/json的內容。

複雜請求表面上看起來和簡單請求使用上差不多,但實際上瀏覽器傳送了不止一個請求。其中最先發送的是一種“預請求”,此時作為服務端,也需要返回“預迴應”作為響應。預請求實際上是對服務端的一種許可權請求,只有當預請求成功返回,實際請求才開始執行。

JavaScript:

var url = 'http://alice.com/cors';
var xhr = createCORSRequest('PUT', url);
xhr.setRequestHeader(
    'X-Custom-Header', 'value');
xhr.send();

預請求:

OPTIONS /cors HTTP/1.1
Origin: http://api.alice.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.bob.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

預請求以OPTIONS形式傳送,當中同樣包含域,並且還包含了兩項CORS特有的內容:

  • Access-Control-Request-Method – 該項內容是實際請求的種類,可以是GET、POST之類的簡單請求,也可以是PUTDELETE等等。
  • Access-Control-Request-Headers – 該項是一個以逗號分隔的列表,當中是複雜請求所使用的頭部。

顯而易見,這個預請求實際上就是在為之後的實際請求傳送一個許可權請求,在預迴應返回的內容當中,服務端應當對這兩項進行回覆,以讓瀏覽器確定請求是否能夠成功完成。例如,剛才的預請求可能獲得服務端如下的迴應:

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8

來看看預迴應當中可能的專案:

  • Access-Control-Allow-Origin(必含) – 和簡單請求一樣的,必須包含一個域。
  • Access-Control-Allow-Methods(必含) – 這是對預請求當中Access-Control-Request-Method的回覆,這一回復將是一個以逗號分隔的列表。儘管客戶端或許只請求某一方法,但服務端仍然可以返回所有允許的方法,以便客戶端將其快取。
  • Access-Control-Allow-Headers(當預請求中包含Access-Control-Request-Headers時必須包含) – 這是對預請求當中Access-Control-Request-Headers的回覆,和上面一樣是以逗號分隔的列表,可以返回所有支援的頭部。
  • Access-Control-Allow-Credentials(可選) – 和簡單請求當中作用相同。
  • Access-Control-Max-Age(可選) – 以秒為單位的快取時間。預請求的的傳送並非免費午餐,允許時應當儘可能快取。

一旦預迴應如期而至,所請求的許可權也都已滿足,則實際請求開始傳送。

實際請求:

PUT /cors HTTP/1.1
Origin: http://api.alice.com
Host: api.bob.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

實際迴應:

Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

如果預請求所要求的許可權服務端不允許,那麼服務端可以直接返回一個普通的HTTP迴應,比如:

// ERROR - No CORS headers, this is an invalid request!
Content-Type: text/html; charset=utf-8

這樣的返回因為不符合客戶端的需求,因而客戶端會直接將請求以失敗計,雖然不是很美氣,不過正符合我們的實際。此時如果客戶端的onerror事件有監聽函式,那麼將會觸發,而瀏覽器的console視窗也會輸出:

XMLHttpRequest cannot load http://api.alice.com. Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

不過很可惜,瀏覽器並不會給出詳細的錯誤情況,僅僅是告知我們出錯而已。

安全問題

跨域請求始終是網頁安全中一個比較頭疼的問題,CORS提供了一種跨域請求方案,但沒有為安全訪問提供足夠的保障機制,如果你需要資訊的絕對安全,不要依賴CORS當中的權限制度,應當使用更多其它的措施來保障,比如OAuth2。

已知問題

CORS是W3C中一項較“新”的方案,以至於各大網頁解析引擎還沒有對其進行完美的實現。下面是截至2011年11月13日時的已知問題:

  • getAllResponseHeaders()方法無法獲取Access-Control-Expose-Headers當中要求的資訊。在Chrome/Safari當中,僅僅只有簡單的頭部能夠讀取,其他無法獲取;在FireFox當中,無法獲得任何資訊。(FireFox Bugzilla/Webkit Bugzilla
  • 在Safari當中,使用GETPOST方法的複雜請求傳送時沒有傳送預請求的環節。
  • onerror觸發時statusText獲取不到任何內容。

閱讀更多