Cross-Origin Resource Sharing (CORS)

Oct 09, 2023

Frontend
Doraemon tasukete
後端 A 夢救救我嗚嗚嗚

遇到 CORS 怎麼辦 😫

找後端 (❌)

又稱跨來源資源共用,當我們使用 JavaScript 取得資源時,非同源的 request 會因為安全性考量而受到限制。瀏覽器會強制你遵守 CORS 的規範,否則 request 會失效。

什麼是同源?

依據 同源政策(Same-origin policy),兩個網頁具備相同協定(protocol)、埠號(port,如果有指定)以及網域(domain),則為同源。而非同源的網站產生的 request 即為跨來源請求(cross-origin http request)。以 https://www.terminal-420.space 來和下面幾個網址相比:

 
1. http://www.terminal-420.space // 通訊協定不同,非同源
2. https://www.terminal-420.com // domain 不同,非同源
3. https://www.cannabis.terminal-420.space // domain 不同,非同源
4. https://www.terminal-420.space:3080 // 埠號不同,非同源
5. https://www.terminal-420.space/blog // 同源
 

而來自於非同源的請求,就稱為跨來源請求

什麼是 CORS?

CORS 是一種機制,其使用額外的 Http header,讓非同源的請求可以被管理、並安全的回覆該請求。

在 JavaScript 中,如果在 Server 沒有額外設定的情況下,所有非同源請求都會被阻擋。而在 CORS 的規範中,跨來源請求分為兩種,簡單(simple)和預檢(preflighted)請求

簡單請求(Simple request)

不需要經過 preflighted(或不觸發 preflighted)就可以直接發送的請求,在 fetch spec 當中稱為 non-preflighted request。僅允許下面三個 HTTP Methods

  • GET
  • HEAD
  • POST

除了 HTTP Methods 之外,簡單請求中只能包含特定的 header field。除了 user agent 自動設定的 header(如 Connection、User-Agent)之外,另外可以加上:

其中,Content-Type 只能包含 application/x-www-form-urlencodedmultipart/form-datatext/plain 這三種。因此下方的請求就不符合簡單請求的標準,因爲 Content-Typeapplication/json

const response = await fetch('https://othersite.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  }
});

如果該請求符合簡單請求的所有規範,那麼瀏覽器會向 Server 發送 request。如果我們在 https://www.terminal-420.space 向另一個 Server 發送一個 request,大概會長的像這樣:

GET /resources/public-data/ HTTP/1.1
Host: someserver.com
User-Agent: ...
Accept: ...
Accept-Language: ...
Accept-Encoding: ...
Accept-Charset: ...
Connection: ...
Referer: ...
Origin: https://www.terminal-420.space

在最後的 Origin header 當中,標示了這個 request 從何而來。這個情境下是 https://www.terminal-420.space 發出請求。再來看看伺服器如何回應這個請求

HTTP/1.1 200 OK
Date: ...
Server: ...
Access-Control-Allow-Origin: *
Keep-Alive: ...
Connection: ...
Transfer-Encoding: ...
Content-Type: application/xml

在 Server 回傳的 header 當中有一個 Access-Control-Allow-Origin,其值為 *,代表他可以允許任何網站跨站請求資源。如果伺服器僅允許特定來源,像是 https://www.terminal-420.space 才能夠存取的話,那麼 Access-Control-Allow-Origin 這個 header 則會回傳:

Access-Control-Allow-Origin: https://www.terminal-420.space

而其他非 https://www.terminal-420.space 的站點都無法存取該資源。

預檢請求(Preflighted request)

其他各種非簡單請求的 request,都必須先經過「預檢(preflighted)」後,才能發送出實際的請求。在請求發送之後,會先經過 CORS check。當數個條件都符合時(其中一個是設置 use-CORS-preflight flag ),此時就會發送預檢請求(CORS-preflight fetch)

CORS-preflight fetch 透過 HTTP OPTIONS 方法來發送,用來確認伺服器可不可以接收這個請求。一個 Preflighted request 大概長得像這樣

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.example
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: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

其中最下方的 Access-Control-Request-MethodAccess-Control-Request-Headers 分別包含了真正要請求的方法以及 headers。在這裡,我們真正想要發送的請求是 POST,其中包含了 X-PINGOTHER 以及 Content-Type 這兩個 headers。

如果這些都符合 Server 的設定,Server 就會回傳 200 OK 的 response

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

其中包含

  • Access-Control-Allow-Origin:Server 允許進行 CORS 請求的 Origins
  • Access-Control-Allow-Methods:Server 允許的請求方法,在這裡包含 POST GETOPTIONS
  • Access-Control-Allow-Headers:Server 允許的 Headers
  • Access-Control-Max-Age:這個 permissions 被快取的時間,這裡是 86400 秒,也就是一天整

而依據 MDN,Status code 200204 No Content 都是允許的 status code,但有些瀏覽器似乎會因為 status 204 而不接著發送後續的 request。

Request with credentials

在預設情況下,跨域的 FetchXMLHttpRequest 都不會帶有 cookie,除非在 request 中帶入特定的 flag。在 Fetch 中需將 credentials 設置為 include

fetch('https://someserver.com/resources/public-data/', {
  credentials: 'include'
})

XMLHttpRequest 則需帶入 withCredentials = true

const invocation = new XMLHttpRequest();
const url = "https://bar.other/resources/credentialed-content/";
 
function callOtherDomain() {
  if (invocation) {
    invocation.open("GET", url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send();
  }
}

另外,無論是簡單請求或需要預檢的請求,若 Server 沒有在 response 中帶入 Access-Control-Allow-Credentials: true 的話,那麼瀏覽器端還是無法看到回傳的資訊。

CORS-preflight cache

CORS-preflight cache 是一串 list,裡面放著 cache 的實體。每一個 CORS-preflight cache 實體包含:

當 cache 實體建立完成後,就會加入到 user agent 的 CORS-preflight cache 當中。

若要移除 cache,會比對 key、byte-serialized origin 以及 URL,找到正確的 cache 後移除。

Express 的 CORS 做了什麼

終於搞懂 CORS 之後,來看看 Express 的 CORS 做了什麼!cors 是 express 的一個 middleware package,用來設置 CORS 的參數。

打開 cors 的 source code 一看,其實也才兩百多行,而且每一個 function 做的事都還蠻清楚的。不同 function 用來設置不同的 header,並以陣列形式儲存,最後用 join 將陣列中的值組合起來,或是回傳陣列繼續組合。

像是設置 Method 的 configureMethods

function configureMethods(options) {
  var methods = options.methods;
  if (methods.join) {
    methods = options.methods.join(','); // .methods is an array, so turn it into a string
  }
  return {
    key: 'Access-Control-Allow-Methods',
    value: methods
  };
}

在 express 裡面寫的這些設定,好像也就很好理解了!

const express = require('express')
const app = express()
const cors = require('cors')
 
app.use(cors({
	origin: "http://127.0.0.1:5500",
	methods: ["GET", "POST"],
	credentials: true
}))

存取私有網路 - RFC1918 CORS

為了防止 cross-site request forgery (CSRF) 透過家中的 router 到達私有或區域網路中的其他設備,Google 提出 CORS for private networks (RFC1918)。其最主要的更動是在 Chrome 94 版之後,無法從不安全的網站(http 就是在說你)發送 request 給私有網路。

而受到影響的存取會有:

  • 公開網路對私有網路的存取(Requests from the public network to a private network)
  • 公開網路對區域網路的存取(Requests from the public network to a local network)
  • 私有網路對區域網路的存取(Requests from a private network to a local network)

那...私有網路存取是什麼

Private network requests are requests whose target server's IP address is more private than that from which the request initiator was fetched. For example, a request from a public website (https://example.com) to a private website (http://router.local), or a request from a private website to localhost.

要搞懂什麼是私有網路存取,就必須先定義什麼是區域、私有及公開網路。

區域 IP 位置(Local IP address space)中包含 IPv4 的 loopback addresses 127.0.0.0/8 及 IPv6 loopback addresses 0:0:0:0:0:0:0:1(或 ::1/128)。

私有 IP 位置(Private IP address space)中包含那些僅在該網域或網路區段有意義的 IP 位置,像是 router 的內部網路。例如 10.0.0.0/8172.16.0.0/12192.168.0.0/16

最後,在這些類別以外的網路均屬於公開網路。

什麼是 loopback address

Loopback address 讓一個系統可以傳送訊息給自己的一段 IP address,可用來確認 TCP/IP stack 是否安裝及正常運作,其實就是 localhost。IPv4 以 127 作為開頭的 IP address 均為 loopback address。

在這三種 IP address 之中,區域網路最為私密,接著是私有網路,最後是公開網路。三者間的關係如下圖所示

Relationship between public, private, local networks in Private Network Access (CORS-RFC1918).
公開、私有及區域網路間的關係 (CORS-RFC1918)

這和 CORS 有什麼關係

在這個新改動中加入了兩個 preflight headers,分別是 Access-Control-Request-Private-NetworkAccess-Control-Allow-Private-Network

在這個改動之後,任何發送給 private network 的 request 都需要先發送 preflight request,且這個 preflight request 會帶入 Access-Control-Request-Private-Network: true 的 header,無論是何種請求方法或模式。若是 Server 允許這個請求,則需要在 response 中帶入 Access-Control-Allow-Private-Network: true 的 header。

此外,如果較不私密的網路(如私有 IP)想要發送請求給更私密的網路(如區域 IP),那麼即便是在 same-origin 模式下的請求,也必須先發送 preflight request 後才能發送正常請求。

來看點實際範例

https://foo.example/index.html 中嵌有一個 image tag <img src="https://bar.example/cat.gif" alt="dancing cat"/>,其圖片位置的 domain bar.example 會解析至 192.168.1.1 這個 IP 位置,為私有網路中的 IP。因此,這個請求即為「公開網路」發送給「私有網路」的請求。

根據上述提到的新規範,Chrome 會送出長這樣的 preflight request,其中帶有 Access-Control-Request-Private-Network: true 這個新的 header。

HTTP/1.1 OPTIONS /cat.gif
Origin: https://foo.example
Access-Control-Request-Private-Network: true

如果要讓這個 preflight request 能夠成功,那麼 server 必須在 response 中帶入 Access-Control-Allow-Private-Network: true header。

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Private-Network: true

最後在 preflight request 成功後,Chrome 便會接續發送真正的 request,將圖片資源請求回來。

HTTP/1.1 GET /cat.gif
...

結論

遇到 CORS 怎麼辦 😫

理直氣壯的找後端 (✅)

Reference

Last updated onFeb 11, 2024

Comments