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

flydean發表於2021-09-13

簡介

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

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

CORS舉例

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

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

比如說客戶端從域http://www.flydean.com向域http://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: http://www.flydean.com
Vary: Origin

上面例子表示伺服器允許接收來自http://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請求,比如現在我們的網站是http://www.flydean.com,在該網站中的某個頁面中,我們希望獲取到https://google.com/data/dataA,那麼我們可以編寫的JS程式碼如下:

const xhr = new XMLHttpRequest();
const url = 'https://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: http://www.flydean.com

這個請求跟CORS有關的就是Origin,表示請求的源域是http://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', 'https://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: http://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: http://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: http://www.flydean.com/index.html
Content-Length: 55
Origin: http://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: http://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 = 'https://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: http://www.flydean.com/index.html
Origin: http://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: http://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請求頭和響應頭之間的互動。

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

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

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

相關文章