javascript 跨域請求詳細分析(終極跨域解決辦法)

自從我接觸前端以來,接手的項目裏面很大部分都是先後端分離的,後端只提供接口,前端根據後端接口渲染出實際頁面。我的以爲這是一個挺好的模式,先後端各自負責各自的模塊,分工明確,並且也給前端更大的發揮空間。javascript

與之前套模板的模式不一樣,先後端分離之後,前端跟後端的溝通絕大部分都是經過前端主動向後端發起請求來完成的。而前端的請求又絕大部分是由 Ajax 構成的,Ajax 是一種很是方便的獲取數據的方式。可是,一旦 Ajax 碰上跨域,那麼問題就會麻煩不少。這篇文章主要梳理了我在項目開發裏面碰到的一些關於跨域請求的問題,固然也會有一些關於跨域請求的一些背景知識。PS:文末有個小彩蛋哦:smile:html

嚴格來講,跨域請求並不只僅只是 Ajax 的跨域請求,而是對於一個頁面來講,只要它請求了其餘域名的資源了,那麼這個過程就屬於跨域請求了。好比,一個帶有其餘域名的 src 的 <img> 標籤,以及頁面中引入的其餘第三方的 CSS 樣式等。前端

對於 img 以及 CSS 而言,跨域請求自己並無更多的安全問題,由於這些請求都屬於只讀請求,並不會對源資源形成反作用。而若是跨域請求是從腳本里面發出去的,因爲腳本具備高度靈活性,瀏覽器出於安全考慮,會根據同源策略來限制它的功能,使得正常狀況下,腳本只能請求同源的資源。若是頁面確實須要經過腳本請求其餘網站的資源,那麼就應當在跨域資源共享(CORS)的機制下工做。java

等等同窗,什麼叫作同源策略?segmentfault

同源策略(Same-origin policy)

對於兩個頁面(資源)而言,只要他們知足如下三個條件則稱他們符合同源策略:後端

  1. 協議相同api

  2. 端口相同跨域

  3. 域名相同瀏覽器

另外, about:blank 和 javascript: 繼承加載這些資源的頁面的 origin。 data: 的資源不一樣,自身會擁有一個空的安全的上下文。緩存

另外,子域能夠經過JS 設置 document.domain 來經過同源策略。如:

在子域 http://a.example.com/test.html 的頁面中,經過 JS 設置 document.domain='example.com' ,則當前頁面與 http://example.com/page.html 符合同源策略。

簡單的說,對於頁面 http://www.example.com/page1.html 來講,如下頁面與它都不符合同源策略,腳本沒法直接請求這些資源:

  • https://www.example.com/page1.html : 協議不一樣

  • http://www.example.com:81/page1.html : 端口不一樣

  • http://another.example.com/page1.html : 域名不一樣

那麼,什麼又是 CORS 呢?

CORS(Cross-Origin Resource Sharing)

CORS 本質上是規定了一系列的 HTTP 頭來做爲判斷腳本是否可以實現跨域請求。在瞭解這些請求頭以前,先來看看跨域請求有哪些類型。

經過腳原本發出請求有兩種方式,一種是經過建立 XMLHttpRequest 的方式來發出請求,另一種是經過 fetch API 來實現請求。

通常來講,跨域請求能夠大體分爲兩種,其中一種稱之爲簡單的請求,其符合如下條件:

  • 請求的方法是 GET 、 POST 、 HEAD 其中之一。

  • 除了瀏覽器自動帶上的請求頭(如 Connection User-Agent 等)以外,只容許下面幾種請求:頭

    • Accept

    • Accept-Language

    • Content-Language

    • Content-Type

  • Content-Type 請求頭的值只能是 application/x-www-form-urlencoded 、 multipart/form-data 、 text/plain 其中之一。

反之,若是有違背上面三條規則中的任意一條,那麼即不是簡單的跨域請求。非簡單的跨域請求相對於簡單的跨域請求來講區別在於,請求在發出去以前,瀏覽器會先發送一個 preflighted 請求,用來向服務器端確認接下來要進行的請求是不是被容許的。

Preflight 請求

在實際項目開發中,在使用 XHR 或者 fetch API 請求接口的時候不少狀況下都會帶上一些額外的特殊請求頭,或者使用特殊的 HTTP 方法,如 PUT 、 DELETE 等(常見於 Restful 接口)。因爲多了額外的請求頭或者使用了特殊的 HTTP 方法,瀏覽器就將這些請求視爲非簡單的跨域請求,將會在實際請求發出去以前先自動發出一個 preflight 請求,也就是一個 OPTIONS 請求。

OPTIONS 請求會將當前的跨域請求所使用的特殊 HTTP 請求頭和 HTTP 請求方法發送給服務器端,如 Access-Control-Request-Method 和 Access-Control-Request-Headers 。服務器端接收到 OPTIONS 請求後返回相應的響應頭。瀏覽器根據返回的響應頭再來判斷該跨域請求是否被容許的。當瀏覽器斷定 OPTIONS 請求經過了,真正的請求才會發出。如如下則是一個帶有 OPTIONS 請求以及真正的 GET 請求的響應頭和請求頭:

OPTIONS /api4 HTTP/1.1Host: us1.serenader.me:3333Connection: keep-alive Pragma: no-cache Cache-Control: no-cache Access-Control-Request-Method: PUT Origin: http://us1.serenader.me:3334User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36Accept: */* Referer: http://us1.serenader.me:3334/ Accept-Encoding: gzip, deflate, sdch Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4,fr;q=0.2
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET,POST,PUT,DELETE Content-Type: text/html; charset=utf-8Content-Length: 2ETag: W/"2-REvLOj/Pg4kpbElGfyfh1g"Date: Thu, 19 Jan 2017 15:21:15 GMT Connection: keep-alive
PUT /api4 HTTP/1.1Host: us1.serenader.me:3333Connection: keep-alive Content-Length: 0Pragma: no-cache Cache-Control: no-cache Origin: http://us1.serenader.me:3334User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36Accept: */* Referer: http://us1.serenader.me:3334/ Accept-Encoding: gzip, deflate, sdch Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4,fr;q=0.2
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Content-Type: text/html; charset=utf-8Content-Length: 2ETag: W/"2-REvLOj/Pg4kpbElGfyfh1g"Date: Thu, 19 Jan 2017 15:21:15 GMT Connection: keep-alive

瞭解了簡單跨域請求以及會發出 preflight 請求的非簡單跨域請求以後,咱們再來看看到底是哪些 HTTP 頭在決定這些跨域請求的「宿命」。

爲了幫助讀者更好地理解這些 HTTP 頭的做用,我編寫了一個簡單的 demo ,開源在了 GitHub 上,感興趣的能夠到 這個連接查看代碼 ,或者訪問這個在線 demo 預覽效果:http://us1.serenader.me:3334/ 。記得加載完頁面後打開 Chrome 的控制檯來查看詳細的請求信息。

Access-Control-Allow-Origin

Access-Control-Allow-Origin 是一個響應頭,它指定了當前資源容許被哪些域名的腳本所請求到。

跨域請求(不管簡單請求仍是非簡單請求)在發出時都會帶上 Origin 請求頭,用來代表當前發出請求的是哪個域名。此時服務器端的響應頭裏面必須包含一個 Access-Control-Allow-Origin 而且該值匹配 Origin 請求頭,這時候該跨域請求才有可能成功。不然一概失敗。

Access-Control-Allow-Origin 是第一道門檻。其值的匹配規則是:

  • 若是其值是通配符 * 的話,則容許全部的域名進行跨域請求

  • 若是其值是指定的某個固定域名,那麼只容許該域名進行跨域請求,其餘域名將會失敗

  • 若是其值是帶有通配符的域名,如 *.example.com ,那麼則容許該域名以及該域名的子域名進行跨域。

具體能夠觀看 demo, demo-0 展現了當腳本請求沒有配置跨域頭的接口時,請求被瀏覽器攔截了的狀況:

demo-1 則展現了接口有配置 Access-Control-Allow-Origin 響應頭,可是並不是腳本請求的域名,此時瀏覽器會報這種錯:

只有配置了正確的 Access-Control-Allow-Origin 響應頭請求才可以正常接收到響應,如 demo-2 ,此時的請求頭和響應頭爲:

GET /api2 HTTP/1.1Host: us1.serenader.me:3333Connection: keep-alive Pragma: no-cache Cache-Control: no-cache Origin: http://us1.serenader.me:3334User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36Accept: */* Referer: http://us1.serenader.me:3334/ Accept-Encoding: gzip, deflate, sdch Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4,fr;q=0.2
HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Content-Type: text/html; charset=utf-8Content-Length: 2ETag: W/"2-REvLOj/Pg4kpbElGfyfh1g"Date: Thu, 19 Jan 2017 15:03:33 GMT Connection: keep-alive

對於簡單的跨域請求來講,一般只須要經過 Access-Control-Allow-Origin 這個響應頭則能夠請求成功(帶 cookie 等狀況先不考慮,會在下面討論)。而當請求不是簡單的跨域請求,狀況就比較複雜。

Access-Control-Allow-Headers

Access-Control-Allow-Headers 是用來告訴瀏覽器當前接口所容許帶上的特殊請求頭是哪些。這個 HTTP 頭通常會出如今 OPTIONS 請求的響應頭中。

當請求設置了一個特殊的請求頭並且所請求的接口並無配置 Access-Control-Allow-Headers 響應頭時,會報以下錯誤,如 demo-3 所示:

上面的截圖展現了請求附帶了一個 X-Custom-Header 的請求頭,可是請求在 preflight 階段就失敗了,若是要讓請求成功完成的話,則必須在 OPTIONS 請求的響應裏面配上 Access-Control-Allow-Headers: X-Custom-Header 。

Access-Control-Allow-Methods

與上一個 HTTP 頭類似, Access-Control-Allow-Methods 告訴瀏覽器當前接口容許使用哪些 HTTP 方法去請求它。這個 HTTP 頭一般也是在 OPTIONS 請求的響應頭中才有意義。當沒有經過這個響應頭時,會報這樣的錯誤:

一樣的,上面的截圖在 preflight 階段就失敗了。若是要讓請求成功執行的話,那麼須要配置響應頭爲: Access-Control-Allow-Methods: GET,POST,PUT 。

Access-Control-Max-Age

因爲 OPTIONS 請求的存在,對於一個非簡單請求來講,實際發出去的請求會有兩個。這多多少少會浪費帶寬,畢竟這個校驗應該只會在第一次發生而已,一旦經過校驗,在接下來的一段時間裏,再次請求該接口的話,那麼實際上 OPTIONS 請求則沒有必要再發出。

好在,有個叫作 Access-Control-Max-Age 的響應頭能夠實現這樣的功能。這個響應頭指定了請求一旦經過了 preflight 請求以後,會在多長時間內無須再次觸發 preflight 請求。從而達到減小實際請求,減小帶寬浪費的問題。

Access-Control-Allow-Credentials

默認狀況下, 任何跨域請求都不會帶上任何身份憑證的,這些身份憑證包括:

  • cookie

  • 與身份認證相關的請求

  • TLS 客戶端證書

然而,在大多數狀況下,咱們須要請求帶上 cookie ,那麼則須要開啓跨域請求的 withCredentials 選項。

想要手動開啓傳輸 cookie 的話,有如下方法;

  • XHR:爲 XHR對象設置 xhr.withCredentials = true 。

  • fetch: 傳入的參數選項裏面開啓 credentials fetch(url, { credentials: 'include' })

開啓了 withCredentials 以後,請求在發出去的時候就會默認加上 Cookie。

然而,除了須要在前端中手動開啓 withCredentials 以外,服務器端也須要有相應響應頭支持,請求才會成功。

Access-Control-Allow-Credentials 這個響應頭則是代表了當前請求的資源是否容許附帶身份憑證。當其值爲 true 時請求才成功,不然會失敗,失敗內容以下:

能夠參考 demo-7 觀看請求頭以及響應頭。

另外, 一旦開啓了 withCredentials 選項,服務器端的 Access-Control-Allow-Origin 響應頭就不能是通配符,只能是固定的一個域名,不然會請求失敗。 具體錯誤內容爲:

demo-8 和 demo-9 分別演示了當請求帶上 cookie 時,響應頭配置爲通配符的狀況以及響應頭有正確配置爲具體域名的狀況。

總結

總的來講,當在腳本里面發出請求時,會有如下狀況:

  1. 所請求資源的協議、端口或者域名若是與當前發出請求的頁面地址一致,那麼則符合同源策略,請求能夠被正常發出。反之,則稱爲跨域請求,須要遵照 CORS 機制。

  2. 全部跨域請求裏面,服務器端必須返回 Access-Control-Allow-Origin 響應頭,而且其值與請求中的 Origin 請求頭的值相匹配。此時請求才能夠被容許,不然請求將會被瀏覽器攔截掉。

  3. 跨域請求分爲兩種,一種是簡單跨域請求,另一種是非簡單跨域請求。非簡單跨域請求在發出請求以前,瀏覽器會先發出一個 preflight 請求,即一個 OPTIONS 請求,用來驗證服務器端是否容許該請求的訪問。當 OPTIONS 請求成功時,纔會繼續發送真正的請求。不然請求將會在 OPTIONS 階段便失敗了,後續真正的請求也不會發出去。

  4. 當請求帶上了特殊的請求頭時,服務器端返回的 OPTIONS 請求的響應必須包含 Access-Control-Allow-Headers 響應頭,而且該值包含請求所帶上的特殊請求頭的名稱。這時候請求才會成功,不然會被瀏覽器攔截。

  5. 當請求使用了特殊的 HTTP 方法,服務器端返回的 OPTIONS 請求的響應必須包含 Access-Control-Allow-Methods 響應頭,而且該值包含當前使用的 HTTP 方法。若是沒有該響應頭,或者當前使用的方法並不在其值裏面,則請求會被瀏覽器攔截。

  6. 由於非簡單請求每次完整請求一次資源實際上都會發出去兩個請求,爲了減小 OPTIONS 請求發出的次數,以便減小帶寬浪費,服務器端能夠配置 Access-Control-Max-Age 來指定瀏覽器能夠在多長時間內對 OPTIONS 請求作緩存,使得一次請求成功後,下次請求相同的接口時不用再發出 OPTIONS 請求。

  7. 當跨域請求須要帶上 cookie 等身份憑證時,須要手動開啓 withCredentials 選項,而且服務器端須要配置 Access-Control-Allow-Credentials 的響應頭,不然請求將不會帶上任何身份憑證,或者當沒有 Access-Control-Allow-Credentials 時請求會被瀏覽器攔截。

  8. 當請求有帶上身份憑證時,服務器端除了須要配置 Access-Control-Allow-Credentials 響應頭以外, Access-Control-Allow-Origin 響應頭的值不能是通配符,必須是具體的某一個域名。不然會被瀏覽器攔截。

在以上 8 點當中,值得注意的是第 3 點和第 8 點。

OPTIONS 請求是一個比較容易被人忽略的一個關鍵點,有一些後端人員在編寫接口的時候,每每只知道在接口的響應頭裏面寫入 Access-Control-Allow-Origin ,而沒有意識到 OPTIONS 請求的存在。特別是 OPTIONS 請求並非每一個跨域請求都會帶上的,這就致使了有些人會有疑問,爲何明明我發出去的是 GET 請求,結果倒是發出去了一個 OPTIONS 請求。而即便有對 OPTIONS 請求作跨域容許的話,那麼也很容易由於缺乏相應的 Access-Control-Allow-Headers 或 Access-Control-Allow-Methods 響應頭致使請求仍然失敗。

第 8 點也是一個很是重要的關鍵點。若是你有接口須要對多個不一樣域名的網站提供服務的話,那麼你的接口就不能使用 cookie 等身份憑證了,畢竟 Access-Control-Allow-Origin 不能設置爲通配符,限制了接口使用的對象。

彩蛋時間

前面提到了只有非簡單請求才會觸發 OPTIONS 請求,而知足簡單請求也就只有那三個條件。可是事實並非想象中的那麼完美。

假如你使用了 XMLHttpRequest 來實現文件上傳的話,若是在 xhr.upload 這個對象裏面添加任何事件監聽,就會觸發 OPTIONS 請求。即便此時該請求自己是知足簡單請求的三個條件的。而一旦把事件監聽去掉就沒有。

這個「bug」是我當初在編寫 uploader 這個庫時無心間發現的,我當時還覺得是瀏覽器的 bug ,可是後來在 Stackoverflow 進行一番搜索後才發現,原來這是瀏覽器隱藏的一個 「feature」。。

Turns out this is not a bug. The spec for XMLHttpRequest does mention that upload progress event handlers should cause the "force preflight" flag to be set. I was a bit confused when this was not specifically mentioned in the CORS spec, even though that spec does reference the existence of a "force preflight" flag.

 

來自:https://segmentfault.com/a/1190000008456994

相關文章
相關標籤/搜索