2022 年 10 月 18 日

擷取:跨來源請求

如果我們向另一個網站發送 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 取得資料,例如天氣

  1. 首先,事先,我們宣告一個全域函式來接受資料,例如 gotWeather

    // 1. Declare the function to process the weather data
    function gotWeather({ temperature, humidity }) {
      alert(`temperature: ${temperature}, humidity: ${humidity}`);
    }
  2. 然後我們建立一個 <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);
  3. 遠端伺服器 another.com 動態產生一個腳本,使用它要我們接收的資料呼叫 gotWeather(...)

    // The expected answer from the server looks like this:
    gotWeather({
      temperature: 25,
      humidity: 78
    });
  4. 當遠端腳本載入並執行時,gotWeather 會執行,而且由於這是我們的函式,所以我們有資料。

這會運作,而且不會違反安全性,因為雙方都同意以這種方式傳遞資料。而且,當雙方都同意時,這絕對不是駭客行為。仍有服務提供這種存取權,因為它甚至適用於非常舊的瀏覽器。

一段時間後,網路方法出現在瀏覽器 JavaScript 中。

一開始,禁止跨來源請求。但經過長時間的討論,允許跨來源請求,但任何需要伺服器明確允許的新功能,都必須以特殊標頭表示。

安全請求

有兩種跨來源請求

  1. 安全請求。
  2. 其他所有請求。

安全請求比較容易建立,所以我們從這裡開始。

如果請求符合兩個條件,則為安全請求

  1. 安全方法:GET、POST 或 HEAD
  2. 安全標頭 – 唯一允許的自訂標頭為
    • Accept,
    • Accept-Language,
    • Content-Language,
    • Content-Type 值為 application/x-www-form-urlencodedmultipart/form-datatext/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),或星號 *。然後回應會成功,否則會出錯。

瀏覽器在此扮演受信任的調解者角色

  1. 它確保正確的 Origin 與跨來源請求一同傳送。
  2. 它檢查回應中是否允許 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-EncodingAPI-Key 標頭。

「不安全」請求

我們可以使用任何 HTTP 方法:不只是 GET/POST,還有 PATCHDELETE 等。

在一段時間前,沒有人能想像網頁可以提出這樣的請求。因此,可能仍然存在將非標準方法視為訊號的網路服務:「那不是瀏覽器」。在檢查存取權限時,它們可能會將其考慮在內。

因此,為了避免誤解,任何「不安全」請求(在過去無法執行,瀏覽器不會立即提出此類請求)。首先,它會傳送初步的、所謂的「預檢」請求,以請求許可。

預檢請求使用 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-urlencodedmultipart/form-datatext/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.

這允許將來的通訊,否則會觸發錯誤。

如果伺服器預期未來有其他方法和標頭,則透過將它們新增到清單中,讓它們提前允許是有意義的。

例如,此回應也允許 PUTDELETE 和其他標頭

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

現在瀏覽器可以看到 PATCHAccess-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-urlencodedmultipart/form-datatext/plain

本質的區別在於,使用 <form><script> 標籤可以執行安全要求,而瀏覽器長期以來無法執行不安全要求。

因此,實際的區別在於安全要求會立即傳送,並附帶 Origin 標頭,而對於其他要求,瀏覽器會提出初步的「預檢」要求,請求許可權。

對於安全要求

  • → 瀏覽器會傳送具有來源的 Origin 標頭。
  • ← 對於沒有憑證的要求(預設不傳送),伺服器應設定
    • Access-Control-Allow-Origin* 或與 Origin 相同的值
  • ← 對於具有憑證的要求,伺服器應設定
    • Access-Control-Allow-Origin 為與 Origin 相同的值
    • Access-Control-Allow-Credentialstrue

此外,若要授予 JavaScript 存取任何回應標頭(Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma 除外),伺服器應在 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 附帶快取許可權的秒數。
  • 然後傳送實際要求,並套用先前的「安全」方案。

任務

重要性:5

您可能知道,HTTP 標頭 Referer 通常包含發起網路要求的頁面網址。

例如,從 https://javascriptinfo.dev.org.tw/some/url 擷取 http://google.com 時,標頭如下所示

Accept: */*
Accept-Charset: utf-8
Accept-Encoding: gzip,deflate,sdch
Connection: keep-alive
Host: google.com
Origin: https://javascriptinfo.dev.org.tw
Referer: https://javascriptinfo.dev.org.tw/some/url

如您所見,RefererOrigin 都存在。

問題

  1. 如果 Referer 擁有更多資訊,為什麼需要 Origin
  2. RefererOrigin 可能不存在嗎?還是不正確?

我們需要 Origin,因為有時 Referer 不存在。例如,當我們從 HTTPS 擷取 HTTP 頁面(從更安全的存取較不安全),則沒有 Referer

內容安全性政策可能會禁止傳送 Referer

正如我們所見,fetch 有選項可以防止傳送 Referer,甚至允許在同一個網站中變更 Referer

根據規範,Referer 是可選的 HTTP 標頭。

正因為 Referer 不可靠,所以發明了 Origin。瀏覽器保證跨來源要求的正確 Origin

教學地圖

留言

留言前請先閱讀…
  • 如果您有改進建議,請提交 GitHub 問題或提出拉取要求,而不是留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行,請將它們包在 <pre> 標籤中,要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)