Web 後端開發者也需要了解的跨域問題
Web 後端開發者很少會去充分了解跨域問題,原因是他們很少和 JavaScript 打交道。但是作為一個 Web 開發者,知道跨域請求和如何解決跨域問題可以和前端開發者在溝通上變得更為順暢。
這篇文章會介紹和跨域請求相關的一些概念,以及如何在後端(Python)解決瀏覽器的跨域請求問題。
一、什麼是跨域請求
首先,我們要了解什麼是跨域請求。簡單來說,當一臺伺服器資源從另一臺伺服器(不同的域名或者埠)請求一個資源時,就會發起一個跨域 HTTP 請求。
舉個簡單的例子, http://example-a.com/index.html
這個 HTML 頁面請求了 http://example-b.com/resource/image.jpg
這個圖片資源時(發起 Ajax 請求,非 <img>
標籤),就是發起了一個跨域請求。
在不做任何處理的情況下,這個跨域請求是無法被成功請求的,因為瀏覽器基於 同源策略 會對跨域請求做一定的限制。
二、瀏覽器同源策略
這就引出了 瀏覽器的同源策略(Same-origin policy) ,同源策略限制了從同一個源載入的文件或者指令碼如何與來自另一個源的資源進行互動。這是一個用於隔離潛在惡意檔案的重要安全機制。
什麼是同源?同源需要同時滿足三個條件:
www.example.com
第 2 點需要注意的是,必須是域名完全相同,比如說 blog.example.com
和 mail.example.com
這兩個域名,雖然它們的頂級域名和二級域名(均為 example.com
)都相同,但是三級域名( blog
和 mail
)不相同,所以也不能算作域名相同。
如果不同時滿足這上面三個條件,那就不符合瀏覽器的同源策略。
修改 document.domain
引數可以更改當前的源,例如 blog.example.com
想要訪問父域 example.com
的資源時,可以執行以下 JavaScript 指令碼來進行修改:
document.domain = 'example.com';
但是 document.domain
不能被設定為 foo.com
或者是 bar.com
,因為它們不是 blog.example.com
的超級域。
當然,也不是所有的互動都會被同源策略攔截下來,下面兩種互動就不會觸發同源策略:
- 跨域寫操作(Cross-origin writes),例如超連結、重定向以及表單的提交操作,特定少數的 HTTP 請求需要新增預檢請求(preflight);
- 跨域資源嵌入(Cross-origin embedding):
-
<script>
標籤嵌入的跨域指令碼; -
<link>
標籤嵌入的 CSS 檔案; -
<img>
標籤嵌入圖片; -
<video>
和<audio>
標籤嵌入多媒體資源; -
<object>
,<embed>
,<applet>
的外掛; -
@font-face
引入的字型,一些瀏覽器允許跨域字型(cross-origin fonts),一些需要同源字型(same-origin fonts); -
<frame>
和<iframe>
載入的任何資源,站點可以使用X-Frame-Options
訊息頭來組織這種形式的跨域互動。
-
如果瀏覽器缺失同源策略這種安全機制會怎麼樣呢?設想一下,當你登陸了 www.bank.com
銀行網站進行操作時,瀏覽器儲存了你登入時的 Cookie 資訊,如果沒有同源策略,在訪問其他網站時,其他網站就可以讀取還未過期的 Cookie 資訊,從而偽造登陸進行操作,造成財產損失。
三、CORS(Cross-origin resource sharing,跨域資源共享)
雖然同源策略一定程度上保證了安全性,但是如果是一個正常的請求需要跨域該怎麼做呢?
常見的方法有四種:
<iframe>
前兩種方式本質上是利用瀏覽器同源策略的漏洞來進行跨域請求,不是推薦的做法,只能作為低版本瀏覽器的緩兵之計。
代理伺服器的做法是讓瀏覽器訪問同源伺服器,再由同源伺服器去訪問目標伺服器,這樣雖然可以避免跨域請求的問題,但是原本只需要一次的請求被請求了兩次,無疑增加了時間的開銷。
目前主流的方法是使用 CORS 的方式,這也是下面主要講的內容。
3.1 什麼是 CORS
CORS 其實是瀏覽器制定的一個規範,它的實現則主要在服務端,它通過一些 HTTP Header 來限制可以訪問的域,例如頁面 A 需要訪問 B 伺服器上的資料,如果 B 伺服器上聲明瞭允許 A 的域名訪問,那麼從 A 到 B 的跨域請求就可以完成。
對於那些會對伺服器資料產生副作用的 HTTP 請求,瀏覽器會使用 OPTIONS
方法發起一個預檢請求(preflight request),從而可以獲知伺服器端是否允許該跨域請求,伺服器端確認允許後,才會發起實際的請求。在預檢請求的返回中,伺服器端也可以告知客戶端是否需要身份認證資訊。
3.2 簡單請求(Simple requests)
某些請求不會觸發 CORS 預檢請求,我們稱這樣的請求為簡單請求。
若請求滿足下面所有條件,則該請求可視為簡單請求:
-
GET
,HEAD
,POST
方法之一; - Header 僅有以下欄位:
Accept Accept-Language Content-Language Content-Type multipart / form-data application / x-www.form-urlencoded DPR Downloadlink Save-Data Viewport-Width Width
- 請求中的任意
XMLHttpRequestUpload
物件均沒有註冊任何事件監聽器,XMLHttpRequestUpload
物件可以使用XMLHttpRequest.upload
屬性訪問; - 請求中沒有使用
ReadableStream
物件。
舉一個例子,例如站點 http://foo.example
的網頁應用想要訪問 http://bar.other
的資源, http://foo.example
的網頁中可能包含類似於下面的 JavaScript 程式碼:
var invocation = new XMLHttpRequest(); var url = 'http://bar.other/resources/public-data/'; function callOtherDomain() { if(invocation) { invocation.open('GET', url, true); invocation.onreadystatechange = handler; invocation.send(); } }
熟悉 JavaScript 的同學可能發現這段程式碼向 http://bar.other/resources/public-data/
發起了一個 GET
請求,請求和響應的報文如下。
請求報文:
響應報文:
在請求報文中, Origin
欄位表明該請求來源於 http://foo.example
。
在響應報文中, Access-Control-Allow-Origin
欄位被設定為 *
,表明該資源可以被任意的域訪問。
使用 Origin
和 Access-Control-Allow-Origin
就能完成最簡單訪問控制。如果服務端僅允許來自 http://foo.example
域的訪問,應該把 進行如下設定:
Access-Control-Allow-Origin: http://foo.example
3.3 預檢請求(Preflight Request)
和簡單請求不同,「需預檢的請求」要求必須先使用 OPTIONS
方法傳送一個預檢請求到伺服器,以獲知伺服器是否允許該請求,或者是否需要攜帶身份認證資訊。「預檢請求」的使用,可以避免跨域請求對伺服器的使用者資料產生未預期的影響。
當一個請求滿足以下任一條件時,該請求需要首先發送預檢請求。
- 使用了下面任一 HTTP 方法:
PUT
、DELETE
、CONNECT
、OPTIONS
、TRACE
、PATCH
; - Header 中設定了除簡單請求 Header 欄位外的其他欄位(見簡單請求中的 Header 欄位說明);
-
Content-Type
的值不屬於下列之一:application/x-www-form-urlencoded multipart/form-data text/plain
- 請求中的
XMLHttpRequestUpload
物件註冊了任意多個事件監聽器; - 請求中使用了
ReadableStream
物件。
例如下面這個例子[ 1]:
var invocation = new XMLHttpRequest(); var url = 'http://bar.other/resources/post-here/'; var body = '<?xml version="1.0"?><person><name>Arun</name></person>'; function callOtherDomain(){ if(invocation) { invocation.open('POST', url, true); invocation.setRequestHeader('X-PINGOTHER', 'pingpong'); invocation.setRequestHeader('Content-Type', 'application/xml'); invocation.onreadystatechange = handler; invocation.send(body); } }
上面的程式碼使用 POST 請求傳送一個 XML 文件,該請求中包含了一個自定義的 Header 欄位 X-PINGOTHER: pingpong
。另外,該請求的 Content-Type
為 application/xml
,因此,該請求需要首先發起「預檢請求」。
OPTIONS 請求報文:
OPTIONS 響應報文:
OPTIONS 方法是 HTTP/1.1 中定義的方法,用以從伺服器獲取更多的資訊,該方法不會對伺服器資源產生影響。預檢請求的 Headers 中攜帶了兩個欄位:
Access-Control-Request-Method: POST Access-Control-Request-Headers: X-PINGOTHER, Content-Type
Access-Control-Request-Method: POST
欄位告訴伺服器,實際請求將使用 POST
方法; Access-Control-Request-Headers
欄位告訴伺服器,實際請求將攜帶兩個自定義請求的 Header 欄位: X-PINGOTHER
和 Content-Type
,伺服器根據此決定,該實際請求是否被允許。
OPTIONS 響應報文表明伺服器將接受後續的實際請求,其中:
Access-Control-Allow-Origin: http://foo.example Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: X-PINGOTHER, Content-Type Access-Control-Max-Age: 86400
-
Access-Control-Allow-Origin
表示允許http://foo.example
的域進行訪問; -
Access-Control-Allow-Methods
表明允許客戶端傳送POST
,GET
,OPTIONS
請求; -
Access-Control-Allow-Headers
表明語序客戶端攜帶X-PINGOTHER
和Content-Type
Header 欄位; -
Access-Control-Max-Age
表明該響應的有效時間為 86400 秒(24 小時),在有效時間內,瀏覽器無需為同一請求再次發起預檢請求。(注,瀏覽器自身維護了一個最大有效時間,如果該 Header 欄位超過了最大有效時間,將不會生效)。
預檢請求完成之後,傳送實際的請求,請求報文如下:
POST /resources/post-here/ HTTP/1.1 Host: bar.other User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre 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 Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7 Connection: keep-alive X-PINGOTHER: pingpong Content-Type: text/xml; charset=UTF-8 Referer: http://foo.example/examples/preflightInvocation.html Content-Length: 55 Origin: http://foo.example Pragma: no-cache Cache-Control: no-cache <?xml version="1.0"?><person><name>Arun</name></person>
響應報文:
HTTP/1.1 200 OK Date: Mon, 01 Dec 2008 01:15:40 GMT Server: Apache/2.0.61 (Unix) Access-Control-Allow-Origin: http://foo.example Vary: Accept-Encoding, Origin Content-Encoding: gzip Content-Length: 235 Keep-Alive: timeout=2, max=99 Connection: Keep-Alive Content-Type: text/plain [Some GZIP'd payload]
3.4 附帶身份認證的請求
一般而言,對於跨域 XMLHTTPRequest
或者 Fetch
請求,瀏覽器不會發送身份憑證資訊,如果需要傳送身份憑證資訊,需要把 XMLHTTPRequest
的 withCredentials
屬性設定為 true
。
舉個例子[
1],下面這段程式碼表示
http://foo.example
向
http://bar.other
傳送一個
GET
請求,並且設定
Cookies
。
var invocation = new XMLHttpRequest(); var url = 'http://bar.other/resources/credentialed-content/'; function callOtherDomain(){ if(invocation) { invocation.open('GET', url, true); invocation.withCredentials = true; invocation.onreadystatechange = handler; invocation.send(); } }
通過把 withCredentials
設定為 true
,從而向伺服器傳送一個攜帶 Cookies
的請求。因為這是一個簡單的 GET
請求,所以瀏覽器不會發起預檢請求,但是,服務端的響應中如果未攜帶 Access-Control-Allow-Credentials: true
,瀏覽器不會把響應內容返回給請求的傳送者。
對於攜帶身份認證的請求,伺服器不得設定 Access-Control-Allow-Origin
的值為 *
。
3.5 用於 CORS 的 Headers
下面列出所有用於 HTTP 請求和響應中的 Header 欄位,具體的使用請查閱 相關文件 。
HTTP 請求 Headers:
Origin Access-Control-Request-Method Access-Control-Request-Headers
HTTP 響應 Headers:
-
Access-Control-Allow-Origin
:指定了允許訪問該資源的外域 URI; -
Access-Control-Expose-Headers
:讓伺服器把允許瀏覽器訪問的頭放入白名單,這樣瀏覽器就能使用getResponseHeader
方法來訪問了; -
Access-Control-Max-Age
:指定了預檢請求的結果能夠被快取多久; -
Access-Control-Allow-Credentials
:指定了當瀏覽器的credentials
設定為 true 時是否允許瀏覽器讀取 response 的內容; -
Access-Control-Allow-Headers
:用於預檢請求的響應。其指明瞭實際請求中允許攜帶的首部欄位。
四、伺服器端實現
為了實現 CORS,在伺服器端需要做一些工作,最主要的就是在響應 Header 中新增指定的欄位。
如果是使用 Python + Flask 的開發的話,可以在 after_app_request
鉤子函式中新增指定的響應頭:
@app.after_app_request def after_request(response): """正常請求結束後的處理""" # ... some code here response.headers['Access-Control-Allow-Origin'] = 'http://example.com' response.headers['Access-Control-Allow-Methods'] = 'GET, PUT, POST, DELETE, HEAD, OPTIONS' response.headers['Access-Control-Allow-Headers'] = ( 'Content-Type, Authorization, X-Requested-With' ) # ... some code here return response
其他語言在對應的鉤子函式中處理即可。