如果我們向另一個網站發送 fetch
請求,請求可能會失敗。
例如,我們嘗試擷取 http://example.com
try {
await fetch('http://example.com');
} catch(err) {
alert(err); // Failed to fetch
}
正如預期,擷取失敗。
此處的核心概念是 來源 – 網域/埠/通訊協定的三元組。
跨來源請求(發送到另一個網域(甚至是子網域)、通訊協定或埠的請求)需要遠端提供特殊標頭。
此政策稱為「CORS」:跨來源資源共用。
為什麼需要 CORS?一個簡短的歷史
CORS 的存在是為了保護網路免於邪惡駭客的侵害。
說真的。讓我們來做一個非常簡短的歷史小插曲。
多年來,一個網站的腳本無法存取另一個網站的內容。
這個簡單但強大的規則是網路安全的基礎。例如,來自網站 hacker.com
的邪惡腳本無法存取使用者在網站 gmail.com
的電子信箱。人們感到很安全。
JavaScript 在當時也沒有任何特殊的方法來執行網路請求。它是一種用來裝飾網頁的玩具語言。
但網路開發人員要求更多的功能。各種技巧被發明出來,以解決限制並向其他網站發出請求。
使用表單
與另一個伺服器通訊的一種方法是在那裡提交一個 <form>
。人們將它提交到 <iframe>
中,只是為了停留在當前頁面,如下所示
<!-- form target -->
<iframe name="iframe"></iframe>
<!-- a form could be dynamically generated and submitted by JavaScript -->
<form target="iframe" method="POST" action="http://another.com/…">
...
</form>
因此,即使沒有網路方法,也可以向另一個網站發出 GET/POST 請求,因為表單可以將資料傳送到任何地方。但由於禁止從另一個網站存取 <iframe>
的內容,因此無法讀取回應。
精確地說,實際上有一些技巧可以做到這一點,它們需要在 iframe 和頁面上使用特殊的腳本。因此,與 iframe 的通訊在技術上是可行的。現在沒有必要深入探討細節,讓這些恐龍安息吧。
使用腳本
另一個技巧是使用 script
標籤。腳本可以有任何 src
,具有任何網域,例如 <script src="http://another.com/…">
。可以執行來自任何網站的腳本。
如果一個網站,例如 another.com
打算公開資料以供這種存取,那麼將使用所謂的「JSONP (帶有填充的 JSON)」協定。
以下是它的運作方式。
假設我們在我們的網站上需要從 http://another.com
取得資料,例如天氣
-
首先,事先,我們宣告一個全域函式來接受資料,例如
gotWeather
。// 1. Declare the function to process the weather data function gotWeather({ temperature, humidity }) { alert(`temperature: ${temperature}, humidity: ${humidity}`); }
-
然後我們建立一個
<script>
標籤,其中src="http://another.com/weather.json?callback=gotWeather"
,使用我們的函式名稱作為callback
URL 參數。let script = document.createElement('script'); script.src = `http://another.com/weather.json?callback=gotWeather`; document.body.append(script);
-
遠端伺服器
another.com
動態產生一個腳本,使用它要我們接收的資料呼叫gotWeather(...)
。// The expected answer from the server looks like this: gotWeather({ temperature: 25, humidity: 78 });
-
當遠端腳本載入並執行時,
gotWeather
會執行,而且由於這是我們的函式,所以我們有資料。
這會運作,而且不會違反安全性,因為雙方都同意以這種方式傳遞資料。而且,當雙方都同意時,這絕對不是駭客行為。仍有服務提供這種存取權,因為它甚至適用於非常舊的瀏覽器。
一段時間後,網路方法出現在瀏覽器 JavaScript 中。
一開始,禁止跨來源請求。但經過長時間的討論,允許跨來源請求,但任何需要伺服器明確允許的新功能,都必須以特殊標頭表示。
安全請求
有兩種跨來源請求
- 安全請求。
- 其他所有請求。
安全請求比較容易建立,所以我們從這裡開始。
如果請求符合兩個條件,則為安全請求
- 安全方法:GET、POST 或 HEAD
- 安全標頭 – 唯一允許的自訂標頭為
Accept
,Accept-Language
,Content-Language
,Content-Type
值為application/x-www-form-urlencoded
、multipart/form-data
或text/plain
。
任何其他請求都視為「不安全」。例如,使用 PUT
方法或 API-Key
HTTP 標頭的請求不符合限制。
主要的差別在於,安全請求可以使用 <form>
或 <script>
建立,而不需要任何特殊方法。
因此,即使是非常舊的伺服器也應該可以接受安全請求。
相反地,具有非標準標頭或例如方法 DELETE
的請求無法以這種方式建立。很長一段時間,JavaScript 無法執行此類請求。因此,舊伺服器可能會假設此類請求來自有權限的來源,「因為網頁無法傳送此類請求」。
當我們嘗試建立不安全請求時,瀏覽器會傳送一個特殊的「預檢」請求,詢問伺服器是否同意接受此類跨來源請求。
而且,除非伺服器明確以標頭確認,否則不會傳送不安全請求。
現在我們將深入探討。
安全請求的 CORS
如果請求是跨來源的,瀏覽器會始終向其新增 Origin
標頭。
例如,如果我們從 https://javascriptinfo.dev.org.tw/page
請求 https://anywhere.com/request
,標頭看起來像
GET /request
Host: anywhere.com
Origin: https://javascriptinfo.dev.org.tw
...
如你所見,Origin
標頭包含確切的來源(網域/協定/連接埠),沒有路徑。
伺服器可以檢查 Origin
,如果它同意接受此類請求,則向回應新增特殊標頭 Access-Control-Allow-Origin
。該標頭應包含允許的來源(在我們的案例中為 https://javascriptinfo.dev.org.tw
),或星號 *
。然後回應會成功,否則會出錯。
瀏覽器在此扮演受信任的調解者角色
- 它確保正確的
Origin
與跨來源請求一同傳送。 - 它檢查回應中是否允許
Access-Control-Allow-Origin
,如果存在,則允許 JavaScript 存取回應,否則會傳回錯誤。
以下是允許伺服器回應的範例
200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
回應標頭
對於跨來源請求,預設情況下 JavaScript 可能只能存取所謂的「安全」回應標頭
Cache-Control
Content-Language
Content-Length
Content-Type
Expires
Last-Modified
Pragma
存取任何其他回應標頭會導致錯誤。
若要授予 JavaScript 存取任何其他回應標頭,伺服器必須傳送 Access-Control-Expose-Headers
標頭。它包含應可存取的不安全標頭名稱的逗號分隔清單。
例如
200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
Content-Encoding: gzip
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
Access-Control-Expose-Headers: Content-Encoding,API-Key
有了這樣的 Access-Control-Expose-Headers
標頭,腳本便可讀取回應的 Content-Encoding
和 API-Key
標頭。
「不安全」請求
我們可以使用任何 HTTP 方法:不只是 GET/POST
,還有 PATCH
、DELETE
等。
在一段時間前,沒有人能想像網頁可以提出這樣的請求。因此,可能仍然存在將非標準方法視為訊號的網路服務:「那不是瀏覽器」。在檢查存取權限時,它們可能會將其考慮在內。
因此,為了避免誤解,任何「不安全」請求(在過去無法執行,瀏覽器不會立即提出此類請求)。首先,它會傳送初步的、所謂的「預檢」請求,以請求許可。
預檢請求使用 OPTIONS
方法,沒有主體和三個標頭
Access-Control-Request-Method
標頭具有不安全請求的方法。Access-Control-Request-Headers
標頭提供其不安全 HTTP 標頭的逗號分隔清單。Origin
標頭會告知要求來自何處。(例如https://javascriptinfo.dev.org.tw
)
如果伺服器同意服務要求,則應以空主體、狀態 200 和標頭回應
Access-Control-Allow-Origin
必須是*
或要求的來源,例如https://javascriptinfo.dev.org.tw
,才能允許它。Access-Control-Allow-Methods
必須具有允許的方法。Access-Control-Allow-Headers
必須具有允許的標頭清單。- 此外,標頭
Access-Control-Max-Age
可以指定快取權限的秒數。因此,瀏覽器不必為滿足特定權限的後續要求傳送預檢請求。
讓我們逐步了解跨來源 PATCH
要求的範例中它是如何運作的(此方法通常用於更新資料)
let response = await fetch('https://site.com/service.json', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'API-Key': 'secret'
}
});
要求不安全的理由有三個(一個就夠了)
- 方法
PATCH
Content-Type
不是下列之一:application/x-www-form-urlencoded
、multipart/form-data
、text/plain
。- 「不安全」的
API-Key
標頭。
步驟 1(預檢請求)
在傳送此類要求之前,瀏覽器會自行傳送預檢請求,如下所示
OPTIONS /service.json
Host: site.com
Origin: https://javascriptinfo.dev.org.tw
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,API-Key
- 方法:
OPTIONS
。 - 路徑 - 與主要求完全相同:
/service.json
。 - 跨來源特殊標頭
Origin
- 來源。Access-Control-Request-Method
- 要求的方法。Access-Control-Request-Headers
- 「不安全」標頭的逗號分隔清單。
步驟 2(預檢回應)
伺服器應以狀態 200 和標頭回應
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type,API-Key
.
這允許將來的通訊,否則會觸發錯誤。
如果伺服器預期未來有其他方法和標頭,則透過將它們新增到清單中,讓它們提前允許是有意義的。
例如,此回應也允許 PUT
、DELETE
和其他標頭
200 OK
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400
現在瀏覽器可以看到 PATCH
在 Access-Control-Allow-Methods
中,而 Content-Type,API-Key
在清單 Access-Control-Allow-Headers
中,因此它會傳送主要求。
如果標頭 Access-Control-Max-Age
帶有秒數,則預檢權限會快取指定時間。上述回應會快取 86400 秒(一天)。在此時間範圍內,後續要求不會導致預檢。假設它們符合快取的允許,它們會直接傳送。
步驟 3(實際要求)
當預檢成功時,瀏覽器現在會提出主要要求。此處的程序與安全要求相同。
主要要求有 Origin
標頭(因為它是跨來源的)
PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascriptinfo.dev.org.tw
步驟 4(實際回應)
伺服器不應忘記將 Access-Control-Allow-Origin
新增到主要回應。成功的預檢並不會免除這一點
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
然後 JavaScript 能讀取主要伺服器回應。
預檢要求會「在幕後」發生,JavaScript 看不到。
JavaScript 僅取得主要要求的回應,或是在沒有伺服器權限時取得錯誤。
憑證
預設情況下,由 JavaScript 程式碼發起的跨來源要求不會帶來任何憑證(Cookie 或 HTTP 驗證)。
這對於 HTTP 要求來說並不常見。通常,對 http://site.com
的要求會附帶該網域的所有 Cookie。另一方面,由 JavaScript 方法提出的跨來源要求是個例外。
例如,fetch('http://another.com')
不會傳送任何 Cookie,甚至包括屬於 another.com
網域的 Cookie。
為什麼?
這是因為有憑證的要求比沒有憑證的要求強大得多。如果允許,它會授予 JavaScript 全權代表使用者採取行動,並使用他們的憑證存取敏感資訊。
伺服器真的那麼信任該指令碼嗎?然後它必須明確允許有憑證的要求,並加上額外的標頭。
要在 fetch
中傳送憑證,我們需要新增選項 credentials: "include"
,如下所示
fetch('http://another.com', {
credentials: "include"
});
現在 fetch
會傳送來自 another.com
的 Cookie,並向該網站提出要求。
如果伺服器同意接受有憑證的要求,它應在回應中新增標頭 Access-Control-Allow-Credentials: true
,並加上 Access-Control-Allow-Origin
。
例如
200 OK
Access-Control-Allow-Origin: https://javascriptinfo.dev.org.tw
Access-Control-Allow-Credentials: true
請注意:Access-Control-Allow-Origin
禁止對具有憑證的要求使用星號 *
。如上所示,它必須提供確切的來源。這是一項額外的安全措施,以確保伺服器確實知道它信任誰可以提出此類要求。
摘要
從瀏覽器的角度來看,有兩種跨來源要求:「安全」和所有其他要求。
「安全」要求必須滿足以下條件
- 方法:GET、POST 或 HEAD。
- 標頭 - 我們只能設定
Accept
Accept-Language
Content-Language
Content-Type
為值application/x-www-form-urlencoded
、multipart/form-data
或text/plain
。
本質的區別在於,使用 <form>
或 <script>
標籤可以執行安全要求,而瀏覽器長期以來無法執行不安全要求。
因此,實際的區別在於安全要求會立即傳送,並附帶 Origin
標頭,而對於其他要求,瀏覽器會提出初步的「預檢」要求,請求許可權。
對於安全要求
- → 瀏覽器會傳送具有來源的
Origin
標頭。 - ← 對於沒有憑證的要求(預設不傳送),伺服器應設定
Access-Control-Allow-Origin
為*
或與Origin
相同的值
- ← 對於具有憑證的要求,伺服器應設定
Access-Control-Allow-Origin
為與Origin
相同的值Access-Control-Allow-Credentials
為true
此外,若要授予 JavaScript 存取任何回應標頭(Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
或 Pragma
除外),伺服器應在 Access-Control-Expose-Headers
標頭中列出允許的標頭。
對於不安全要求,會在要求之前發出初步的「預檢」要求
- → 瀏覽器會傳送
OPTIONS
要求到相同的 URL,並附帶標頭Access-Control-Request-Method
已要求方法。Access-Control-Request-Headers
列出不安全的已要求標頭。
- ← 伺服器應以狀態 200 和標頭回應
Access-Control-Allow-Methods
附帶允許方法的清單,Access-Control-Allow-Headers
附帶允許標頭的清單,Access-Control-Max-Age
附帶快取許可權的秒數。
- 然後傳送實際要求,並套用先前的「安全」方案。
留言
<code>
標籤,要插入多行,請將它們包在<pre>
標籤中,要插入超過 10 行,請使用沙盒 (plnkr、jsbin、codepen…)