安全系列之:跨域資源共享CORS

簡介

什麼是跨域資源共享呢? 我們知道一個域是由scheme、domain和port三部分來組成的,這三個部分可以唯一標記一個域,或者一個服務器請求的地址。跨域資源共享的意思就是服務器允許其他的域來訪問它自己域的資源。

CORS是一個基於HTTP-header檢測的機制,本文將會詳細對其進行說明。

CORS舉例

為了安全起見,一般一個域發起的請求只能獲取該域自己的資源,因為域資源內部的互相調用被認為是安全的。

但是隨着現代瀏覽器技術和ajax技術的發展,漸漸的出現了從javascript中去請求其他域資源的需求,我們把這樣的需求叫做跨域請求。

比如說客戶端從域//www.flydean.com向域//www.abc.com/data.json請求數據。

那麼客戶端是怎麼知道服務器是否支持CORS的呢?

這裡會使用到一個叫做preflight的請求,這個請求只是向服務器確認是否支持要訪問資源的跨域請求,當客戶端得到響應之後,才會真正的去請求服務器中的跨域資源。

雖然是客戶端去設置HTTP請求的header來進行CORS請求,但是服務端也需要進行一些設置來保證能夠響應客戶端的請求。所以本文同時適合前端開發者和後端開發者。

CORS protocol

沒錯,任意一種請求要想標準化,那麼必須制定標準的協議,CORS也一樣,CORS protocol主要定義了HTTP中的請求頭和響應頭。我們分別來詳細了解。

HTTP request headers

首先是HTTP的請求頭。請求頭是客戶端請求資源時所帶的數據。CORS請求頭主要包含三部分。

第一部分是Origin,表示發起跨域資源請求的request或者preflight request源:

Origin: <origin>

Origin只包含server name信息,並不包含任何PATH信息。

注意,Origin的值可能為null

第二部分是Access-Control-Request-Method,這是一個preflight request,告訴服務器下一次真正會使用的HTTP資源請求方法:

Access-Control-Request-Method: <method>

第三部分是Access-Control-Request-Headers,同樣也是一個preflight request,告訴服務器下一次真正使用的HTTP請求中要帶的header數據。header中的數據是和server端的Access-Control-Allow-Headers相對應的。

Access-Control-Request-Headers: <field-name>[, <field-name>]*

HTTP response headers

有了客戶端的請求,還需要服務器端的響應,我們看下服務器端都需要設置那些HTTP header數據。

  1. Access-Control-Allow-Origin

Access-Control-Allow-Origin表示服務器允許的CORS的域,可以指定特定的域,也可以使用*表示接收所有的域。

Access-Control-Allow-Origin: <origin> | *

要注意的是,如果請求帶有認證信息,則不能使用*。

我們看一個例子:

Access-Control-Allow-Origin: //www.flydean.com
Vary: Origin

上面例子表示服務器允許接收來自//www.flydean.com的請求,這裡指定了具體的某一個域,而不是使用*。因為服務器端可以設置一個允許的域列表,所以這裡返回的只是其中的一個域地址,所以還需要在下面加上一個Vary:Origin頭信息,表示Access-Control-Allow-Origin會隨客戶端請求頭中的Origin信息自動發送變化。

  1. Access-Control-Expose-Headers

Access-Control-Expose-Headers表示服務器端允許客戶端或者CORS資源的同時能夠訪問到的header信息。其格式如下:

Access-Control-Expose-Headers: <header-name>[, <header-name>]*

例如:

Access-Control-Expose-Headers: Custom-Header1, Custom-Header2

上面的例子將向客戶端暴露Custom-Header1, Custom-Header2兩個header,客戶端可以獲取到這兩個header的值。

  1. Access-Control-Max-Age

Access-Control-Max-Age表示preflight request的請求結果將會被緩存多久,其格式如下:

Access-Control-Max-Age: <delta-seconds>

delta-seconds是以秒為單位。

  1. Access-Control-Allow-Credentials

這個字段用來表示服務器端是否接受客戶端帶有credentials字段的請求。如果用在preflight請求中,則表示後續的真實請求是否支持credentials,其格式如下:

Access-Control-Allow-Credentials: true
  1. Access-Control-Allow-Methods

這個字段表示訪問資源允許的方法,主要用在preflight request中。其格式如下:

Access-Control-Allow-Methods: <method>[, <method>]*
  1. Access-Control-Allow-Headers

用在preflight request中,表示真正能夠被用來做請求的header字段,其格式如下:

Access-Control-Allow-Headers: <header-name>[, <header-name>]*

有了CORS協議的基本概念之後,我們就可以開始使用CORS來構建跨域資源訪問了。

基本CORS

先來看一個最基本的CORS請求,比如現在我們的網站是//www.flydean.com,在該網站中的某個頁面中,我們希望獲取到//google.com/data/dataA,那麼我們可以編寫的JS代碼如下:

const xhr = new XMLHttpRequest();
const url = '//google.com/data/dataA';

xhr.open('GET', url);
xhr.onreadystatechange = someHandler;
xhr.send();

該請求是一個最基本的CORS請求,我們看下客戶端發送的請求包含哪些數據:

GET /data/dataA HTTP/1.1
Host: google.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: //www.flydean.com

這個請求跟CORS有關的就是Origin,表示請求的源域是//www.flydean.com。

可能的返回結果如下:

HTTP/1.1 200 OK
Date: Mon, 01 May 2021 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…Data…]

上面的返回結果要注意的是Access-Control-Allow-Origin: *,表示服務器允許所有的Origin請求。

Preflighted requests

上面的例子是一個最基本的請求,客戶端直接向服務器端請求資源。接下來我們看一個Preflighted requests的例子,Preflighted requests的請求分兩部分,第一部分是請求判斷,第二部分才是真正的請求。

注意,GET請求是不會發送preflighted的。

什麼時候會發送Preflighted requests呢?

當客戶端發送OPTIONS方法給服務器的時候,為了安全起見,因為服務器並不一定能夠接受這些OPTIONS的方法,所以客戶端需要首先發送一個
preflighted requests,等待服務器響應,等服務器確認之後,再發送真實的請求。我們舉一個例子。

const xhr = new XMLHttpRequest();
xhr.open('POST', '//google.com/data/dataA');flydean
xhr.setRequestHeader('cust-head', 'www.flydean.com');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<site>www.flydean.com</site>');

上例中,我們向服務器端發送了一個POST請求,在這個請求中我們添加了一個自定義的header:cust-head。因為這個header並不是HTTP1.1中標準的header,所以需要發送一個Preflighted requests先。

OPTIONS /data/dataA HTTP/1.1
Host: google.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: //www.flydean.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: cust-head, Content-Type

請求中添加了Access-Control-Request-Method和Access-Control-Request-Headers這兩個多出來的字段。

得到的服務器響應如下:

HTTP/1.1 204 No Content
Date: Mon, 01 May 2021 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: //www.flydean.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: cust-head, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

響應中返回了Access-Control-Allow-Origin,Access-Control-Allow-Methods和Access-Control-Allow-Headers。

當客戶端收到服務器的響應之後,發現配後續的請求,就可以繼續發送真實的請求了:

POST /data/dataA HTTP/1.1
Host: google.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
cust-head: www.flydean.com
Content-Type: text/xml; charset=UTF-8
Referer: //www.flydean.com/index.html
Content-Length: 55
Origin: //www.flydean.com
Pragma: no-cache
Cache-Control: no-cache

<site>www.flydean.com</site>

在真實的請求中,我們不需要再發送Access-Control-Request*頭標記了,只需要發送真實的請求數據即可。

最後,我們得到server端的響應:

HTTP/1.1 200 OK
Date: Mon, 01 May 2021 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: //www.flydean.com
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some data]

帶認證的請求

有時候,我們需要訪問的資源需要帶認證信息,這些認證信息是通過HTTP cookies來進行傳輸的,但是對於瀏覽器來說,默認情況下是不會進行認證的。要想進行認證,必須設置特定的標記:

const invocation = new XMLHttpRequest();
const url = '//google.com/data/dataA';

function corscall() {
  if (invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send();
  }
}

上面的例子中,我們設置了withCredentials flag,表示這是一個帶認證的請求。

其對應的請求如下:

GET data/dataA HTTP/1.1
Host: google.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: //www.flydean.com/index.html
Origin: //www.flydean.com
Cookie: name=flydean

請求中我們帶上了Cookie,服務器對應的響應如下:

HTTP/1.1 200 OK
Date: Mon, 01 May 2021 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: //www.flydean.com
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: name=flydean; expires=Wed, 31-May-2021 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

[text/plain payload]

服務器返回了Access-Control-Allow-Credentials: true,表示服務器接收credentials認證,並且返回了Set-Cookie選項對客戶端的cookie進行更新。

要注意的是如果服務器支持credentials,那麼返回的Access-Control-Allow-Origin,Access-Control-Allow-Headers和Access-Control-Allow-Methods的值都不能是*。

總結

本文簡單介紹了HTTP協議中的CORS協議,要注意的是CORS實際上是HTTP請求頭和響應頭之間的交互。

本文已收錄於 //www.flydean.com/cors/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程序那些事」,懂技術,更懂你!