Javascript Fetch:跨源請求

2023-02-17 10:57 更新

如果我們向另一個網(wǎng)站發(fā)送 ?fetch? 請求,則該請求可能會失敗。

例如,讓我們嘗試向 http://example.com 發(fā)送 fetch 請求:

try {
  await fetch('http://example.com');
} catch(err) {
  alert(err); // fetch 失敗
}

正如所料,獲取失敗。

這里的核心概念是 源(origin)—— 域(domain)/端口(port)/協(xié)議(protocol)的組合。

跨源請求 —— 那些發(fā)送到其他域(即使是子域)、協(xié)議或端口的請求 —— 需要來自遠(yuǎn)程端的特殊 header。

這個策略被稱為 “CORS”:跨源資源共享(Cross-Origin Resource Sharing)。

為什么需要 CORS?跨源請求簡史

CORS 的存在是為了保護(hù)互聯(lián)網(wǎng)免受黑客攻擊。

說真的,在這說點兒題外話,講講它的歷史。

多年來,來自一個網(wǎng)站的腳本無法訪問另一個網(wǎng)站的內(nèi)容。

這個簡單有力的規(guī)則是互聯(lián)網(wǎng)安全的基礎(chǔ)。例如,來自 hacker.com 的腳本無法訪問 gmail.com 上的用戶郵箱?;谶@樣的規(guī)則,人們感到很安全。

在那時候,JavaScript 并沒有任何特殊的執(zhí)行網(wǎng)絡(luò)請求的方法。它只是一種用來裝飾網(wǎng)頁的玩具語言而已。

但是 Web 開發(fā)人員需要更多功能。人們發(fā)明了各種各樣的技巧去突破該限制,并向其他網(wǎng)站發(fā)出請求。

使用表單

其中一種和其他服務(wù)器通信的方法是在那里提交一個 <form>。人們將它提交到 <iframe>,只是為了停留在當(dāng)前頁面,像這樣:

<!-- 表單目標(biāo) -->
<iframe name="iframe"></iframe>

<!-- 表單可以由 JavaScript 動態(tài)生成并提交 -->
<form target="iframe" method="POST" action="http://another.com/…">
  ...
</form>

因此,即使沒有網(wǎng)絡(luò)方法,也可以向其他網(wǎng)站發(fā)出 GET/POST 請求,因為表單可以將數(shù)據(jù)發(fā)送到任何地方。但是由于禁止從其他網(wǎng)站訪問 <iframe> 中的內(nèi)容,因此就無法讀取響應(yīng)。

確切地說,實際上有一些技巧能夠解決這個問題,這在 iframe 和頁面中都需要添加特殊腳本。因此,與 iframe 的通信在技術(shù)上是可能的?,F(xiàn)在我們沒必要講其細(xì)節(jié)內(nèi)容,我們還是讓這些古董代碼不要再出現(xiàn)了吧。

使用 script

另一個技巧是使用 script 標(biāo)簽。script 可以具有任何域的 src,例如 <script src="http://another.com/…" rel="external nofollow" >。也可以執(zhí)行來自任何網(wǎng)站的 script

如果一個網(wǎng)站,例如 another.com 試圖公開這種訪問方式的數(shù)據(jù),則會使用所謂的 “JSONP (JSON with padding)” 協(xié)議。

這是它的工作方式。

假設(shè)在我們的網(wǎng)站,需要以這種方式從 http://another.com 網(wǎng)站獲取數(shù)據(jù),例如天氣:

  1. 首先,我們先聲明一個全局函數(shù)來接收數(shù)據(jù),例如 ?gotWeather?。
  2. // 1. 聲明處理天氣數(shù)據(jù)的函數(shù)
    function gotWeather({ temperature, humidity }) {
      alert(`temperature: ${temperature}, humidity: ${humidity}`);
    }
  3. 然后我們創(chuàng)建一個特性(attribute)為 src="http://another.com/weather.json?callback=gotWeather" rel="external nofollow"  的 <script> 標(biāo)簽,使用我們的函數(shù)名作為它的 callback URL-參數(shù)。
  4. let script = document.createElement('script');
    script.src = `http://another.com/weather.json?callback=gotWeather`;
    document.body.append(script);
  5. 遠(yuǎn)程服務(wù)器 another.com 動態(tài)生成一個腳本,該腳本調(diào)用 gotWeather(...),發(fā)送它想讓我們接收的數(shù)據(jù)。
  6. // 我們期望來自服務(wù)器的回答看起來像這樣:
    gotWeather({
      temperature: 25,
      humidity: 78
    });
  7. 當(dāng)遠(yuǎn)程腳本加載并執(zhí)行時,?gotWeather? 函數(shù)將運行,并且因為它是我們的函數(shù),我們就有了需要的數(shù)據(jù)。

這是可行的,并且不違反安全規(guī)定,因為雙方都同意以這種方式傳遞數(shù)據(jù)。而且,既然雙方都同意這種行為,那這肯定不是黑客攻擊了?,F(xiàn)在仍然有提供這種訪問的服務(wù),因為即使是非常舊的瀏覽器它依然適用。

不久之后,網(wǎng)絡(luò)方法出現(xiàn)在了瀏覽器 JavaScript 中。

起初,跨源請求是被禁止的。但是,經(jīng)過長時間的討論,跨源請求被允許了,但是任何新功能都需要服務(wù)器明確允許,以特殊的 header 表述。

安全請求

有兩種類型的跨源請求:

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

安全請求很簡單,所以我們先從它開始。

如果一個請求滿足下面這兩個條件,則該請求是安全的:

  1. 安全的方法:GET,POST 或 HEAD
  2. 安全的 header —— 僅允許自定義下列 header:
    • ?Accept?,
    • ?Accept-Language?,
    • ?Content-Language?,
    • ?Content-Type? 的值為 ?application/x-www-form-urlencoded?,?multipart/form-data? 或 ?text/plain?。

任何其他請求都被認(rèn)為是“非安全”請求。例如,具有 PUT 方法或 API-Key HTTP-header 的請求就不是安全請求。

本質(zhì)區(qū)別在于,可以使用 <form> 或 <script> 進(jìn)行安全請求,而無需任何其他特殊方法。

因此,即使是非常舊的服務(wù)器也能很好地接收安全請求。

與此相反,帶有非標(biāo)準(zhǔn) header 或者例如 DELETE 方法的請求,無法通過這種方式創(chuàng)建。在很長一段時間里,JavaScript 都不能進(jìn)行這樣的請求。所以,舊的服務(wù)器可能會認(rèn)為此類請求來自具有特權(quán)的來源(privileged source),“因為網(wǎng)頁無法發(fā)送它們”。

當(dāng)我們嘗試發(fā)送一個非安全請求時,瀏覽器會發(fā)送一個特殊的“預(yù)檢(preflight)”請求到服務(wù)器 —— 詢問服務(wù)器,你接受此類跨源請求嗎?

并且,除非服務(wù)器明確通過 header 進(jìn)行確認(rèn),否則非安全請求不會被發(fā)送。

現(xiàn)在,我們來詳細(xì)介紹它們。

用于安全請求的 CORS

如果一個請求是跨源的,瀏覽器始終會向其添加 Origin header。

例如,如果我們從 https://javascript.info/page 請求 https://anywhere.com/request,請求的 header 將如下所示:

GET /request
Host: anywhere.com
Origin: https://javascript.info
...

正如你所看到的,Origin 包含了確切的源(domain/protocol/port),沒有路徑(path)。

服務(wù)器可以檢查 Origin,如果同意接受這樣的請求,就會在響應(yīng)中添加一個特殊的 header Access-Control-Allow-Origin。該 header 包含了允許的源(在我們的示例中是 https://javascript.info),或者一個星號 *。然后響應(yīng)成功,否則報錯。

瀏覽器在這里扮演受被信任的中間人的角色:

  1. 它確保發(fā)送的跨源請求帶有正確的 ?Origin?。
  2. 它檢查響應(yīng)中的許可 ?Access-Control-Allow-Origin?,如果存在,則允許 JavaScript 訪問響應(yīng),否則將失敗并報錯。


這是一個帶有服務(wù)器許可的響應(yīng)示例:

200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: https://javascript.info

Response header

對于跨源請求,默認(rèn)情況下,JavaScript 只能訪問“安全的” response header:

  • ?Cache-Control?
  • ?Content-Language?
  • ?Content-Type?
  • ?Expires?
  • ?Last-Modified?
  • ?Pragma?

訪問任何其他 response header 都將導(dǎo)致 error。

請注意:

請注意:列表中沒有 Content-Length header!

該 header 包含完整的響應(yīng)長度。因此,如果我們正在下載某些內(nèi)容,并希望跟蹤進(jìn)度百分比,則需要額外的權(quán)限才能訪問該 header(請見下文)。

要授予 JavaScript 對任何其他 response header 的訪問權(quán)限,服務(wù)器必須發(fā)送 Access-Control-Expose-Headers header。它包含一個以逗號分隔的應(yīng)該被設(shè)置為可訪問的非安全 header 名稱列表。

例如:

200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Expose-Headers: Content-Length,API-Key

有了這種 Access-Control-Expose-Headers header,此腳本就被允許讀取響應(yīng)的 Content-Length 和 API-Key header。

“非安全”請求

我們可以使用任何 HTTP 方法:不僅僅是 GET/POST,也可以是 PATCH,DELETE 及其他。

之前,沒有人能夠設(shè)想網(wǎng)頁能發(fā)出這樣的請求。因此,可能仍然存在有些 Web 服務(wù)將非標(biāo)準(zhǔn)方法視為一個信號:“這不是瀏覽器”。它們可以在檢查訪問權(quán)限時將其考慮在內(nèi)。

因此,為了避免誤解,任何“非安全”請求 —— 在過去無法完成的,瀏覽器不會立即發(fā)出此類請求。首先,它會先發(fā)送一個初步的、所謂的“預(yù)檢(preflight)”請求,來請求許可。

預(yù)檢請求使用 OPTIONS 方法,它沒有 body,但是有三個 header:

  • ?Access-Control-Request-Method header? 帶有非安全請求的方法。
  • ?Access-Control-Request-Headers header? 提供一個以逗號分隔的非安全 HTTP-header 列表。

如果服務(wù)器同意處理請求,那么它會進(jìn)行響應(yīng),此響應(yīng)的狀態(tài)碼應(yīng)該為 200,沒有 body,具有 header:

  • ?Access-Control-Allow-Origin? 必須為 ?*? 或進(jìn)行請求的源(例如 ?https://javascript.info?)才能允許此請求。
  • ?Access-Control-Allow-Methods? 必須具有允許的方法。
  • ?Access-Control-Allow-Headers? 必須具有一個允許的 header 列表。
  • 另外,header ?Access-Control-Max-Age? 可以指定緩存此權(quán)限的秒數(shù)。因此,瀏覽器不是必須為滿足給定權(quán)限的后續(xù)請求發(fā)送預(yù)檢。


讓我們在一個跨源 PATCH 請求的例子中一步一步地看它是如何工作的(此方法經(jīng)常被用于更新數(shù)據(jù)):

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? header。

Step 1 預(yù)檢請求(preflight request)

在發(fā)送我們的請求前,瀏覽器會自己發(fā)送如下所示的預(yù)檢請求:

OPTIONS /service.json
Host: site.com
Origin: https://javascript.info
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? —— 以逗號分隔的“非安全” header 列表。

Step 2 預(yù)檢響應(yīng)(preflight response)

服務(wù)應(yīng)響應(yīng)狀態(tài) 200 和 header:

  • ?Access-Control-Allow-Origin: https://javascript.info?
  • ?Access-Control-Allow-Methods: PATCH?
  • ?Access-Control-Allow-Headers: Content-Type,API-Key?。

這將允許后續(xù)通信,否則會觸發(fā)錯誤。

如果服務(wù)器將來需要其他方法和 header,則可以通過將這些方法和 header 添加到列表中來預(yù)先允許它們。

例如,此響應(yīng)還允許 ?PUT?、?DELETE? 以及其他 header:

200 OK
Access-Control-Allow-Origin: https://javascript.info
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

現(xiàn)在,瀏覽器可以看到 PATCH 在 Access-Control-Allow-Methods 中,Content-Type,API-Key 在列表 Access-Control-Allow-Headers 中,因此它將發(fā)送主請求。

如果 Access-Control-Max-Age 帶有一個表示秒的數(shù)字,則在給定的時間內(nèi),預(yù)檢權(quán)限會被緩存。上面的響應(yīng)將被緩存 86400 秒,也就是一天。在此時間范圍內(nèi),后續(xù)請求將不會觸發(fā)預(yù)檢。假設(shè)它們符合緩存的配額,則將直接發(fā)送它們。

Step 3 實際請求(actual request)

預(yù)檢成功后,瀏覽器現(xiàn)在發(fā)出主請求。這里的過程與安全請求的過程相同。

主請求具有 Origin header(因為它是跨源的):

PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.info

Step 4 實際響應(yīng)(actual response)

服務(wù)器不應(yīng)該忘記在主響應(yīng)中添加 Access-Control-Allow-Origin。成功的預(yù)檢并不能免除此要求:

Access-Control-Allow-Origin: https://javascript.info

然后,JavaScript 可以讀取主服務(wù)器響應(yīng)了。

請注意:

預(yù)檢請求發(fā)生在“幕后”,它對 JavaScript 不可見。

JavaScript 僅獲取對主請求的響應(yīng),如果沒有服務(wù)器許可,則獲得一個 error。

憑據(jù)(Credentials)

默認(rèn)情況下,由 JavaScript 代碼發(fā)起的跨源請求不會帶來任何憑據(jù)(cookies 或者 HTTP 認(rèn)證(HTTP authentication))。

這對于 HTTP 請求來說并不常見。通常,對 http://site.com 的請求附帶有該域的所有 cookie。但是由 JavaScript 方法發(fā)出的跨源請求是個例外。

例如,fetch('http://another.com') 不會發(fā)送任何 cookie,即使那些 (!) 屬于 another.com 域的 cookie。

為什么?

這是因為具有憑據(jù)的請求比沒有憑據(jù)的請求要強(qiáng)大得多。如果被允許,它會使用它們的憑據(jù)授予 JavaScript 代表用戶行為和訪問敏感信息的全部權(quán)力。

服務(wù)器真的這么信任這種腳本嗎?是的,它必須顯式地帶有允許請求的憑據(jù)和附加 header。

要在 fetch 中發(fā)送憑據(jù),我們需要添加 credentials: "include" 選項,像這樣:

fetch('http://another.com', {
  credentials: "include"
});

現(xiàn)在,fetch 將把源自 another.com 的 cookie 和我們的請求發(fā)送到該網(wǎng)站。

如果服務(wù)器同意接受 帶有憑據(jù) 的請求,則除了 Access-Control-Allow-Origin 外,服務(wù)器還應(yīng)該在響應(yīng)中添加 header Access-Control-Allow-Credentials: true。

例如:

200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Credentials: true

請注意:對于具有憑據(jù)的請求,禁止 Access-Control-Allow-Origin 使用星號 *。如上所示,它必須有一個確切的源。這是另一項安全措施,以確保服務(wù)器真的知道它信任的發(fā)出此請求的是誰。

總結(jié)

從瀏覽器角度來看,有兩種跨源請求:“安全”請求和其他請求。

“安全”請求必須滿足以下條件:

  • 方法:GET,POST 或 HEAD。
  • header —— 我們僅能設(shè)置:
    • ?Accept?
    • ?Accept-Language?
    • ?Content-Language?
    • ?Content-Type? 的值為 ?application/x-www-form-urlencoded?,?multipart/form-data? 或 ?text/plain?。

安全請求和其他請求的本質(zhì)區(qū)別在于,自古以來就可以使用 <form> 或 <script> 標(biāo)簽來實現(xiàn)安全請求,而對于瀏覽器來說,非安全請求在很長一段時間都是不可能的。

所以,實際區(qū)別在于,安全請求會立即發(fā)送,并帶有 Origin header,而對于其他請求,瀏覽器會發(fā)出初步的“預(yù)檢”請求,以請求許可。

對于安全請求:

  • → 瀏覽器發(fā)送帶有源的 ?Origin? header。
  • ← 對于沒有憑據(jù)的請求(默認(rèn)不發(fā)送),服務(wù)器應(yīng)該設(shè)置:
    • ?Access-Control-Allow-Origin? 為 ?*? 或與 ?Origin? 的值相同
  • ← 對于具有憑據(jù)的請求,服務(wù)器應(yīng)該設(shè)置:
    • ?Access-Control-Allow-Origin? 值與 ?Origin? 的相同
    • ?Access-Control-Allow-Credentials? 為 ?true?

此外,要授予 JavaScript 訪問除 Cache-Control,Content-Language,Content-Type,Expires,Last-Modified 或 Pragma 外的任何 response header 的權(quán)限,服務(wù)器應(yīng)該在 header Access-Control-Expose-Headers 中列出允許的那些 header。

對于非安全請求,會在請求之前發(fā)出初步“預(yù)檢”請求:

  • → 瀏覽器將具有以下 header 的 ?OPTIONS? 請求發(fā)送到相同的 URL:
    • ?Access-Control-Request-Method? 有請求方法。
    • ?Access-Control-Request-Headers? 以逗號分隔的“非安全” header 列表。
  • ← 服務(wù)器應(yīng)該響應(yīng)狀態(tài)碼為 200 和 header:
    • ?Access-Control-Allow-Methods? 帶有允許的方法的列表,
    • ?Access-Control-Allow-Headers? 帶有允許的 header 的列表,
    • ?Access-Control-Max-Age? 帶有指定緩存權(quán)限的秒數(shù)。
  • 然后,發(fā)送實際的請求,并應(yīng)用之前的“安全”方案。

任務(wù)


我們?yōu)槭裁葱枰矗∣rigin)?

重要程度: 5

你可能知道有一個 HTTP-header Referer,它通常包含發(fā)起網(wǎng)絡(luò)請求的頁面的 url。

例如,當(dāng)從 http://javascript.info/some/url fetch http://google.com 時,header 看起來如下:

Accept: */*
Accept-Charset: utf-8
Accept-Encoding: gzip,deflate,sdch
Connection: keep-alive
Host: google.com
Origin: http://javascript.info
Referer: http://javascript.info/some/url

正如你所看到的,存在 Referer 和 Origin。

問題是:

  1. 為什么需要 ?Origin?,如果 ?Referer? 甚至具有更多信息?
  2. 如果這里沒有 ?Referer? 或 ?Origin? 可行嗎,還是說會出問題?

解決方案

我們需要 Origin,是因為有時會沒有 Referer。例如,當(dāng)我們從 HTTPS(從高安全性訪問低安全性)fetch HTTP 頁面時,便沒有 Referer。

內(nèi)容安全策略 可能會禁止發(fā)送 Referer。

正如我們將看到的,fetch 也具有阻止發(fā)送 Referer 的選項,甚至允許修改它(在同一網(wǎng)站內(nèi))。

根據(jù)規(guī)范,Referer 是一個可選的 HTTP-header。

正是因為 Referer 不可靠,才發(fā)明了 Origin。瀏覽器保證跨源請求的正確 Origin。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號