瀏覽器同源策略和跨域方法
前言
瞭解瀏覽器的同源策略和各種跨域方式是所有前端都必須熟練掌握的知識,因為在開發的過程中遇到跨域請求是常有的事情,包括我們自己mock
資料的時候也可能遇到跨域的問題,如果不能理解同源策略那麼每次遇到跨域都可能不能快速解決。
同源策略
同源策略是瀏覽器的策略,會在請求從服務返回的時候檢查響應頭中的Access-Control-Allow-Origin
和 請求頭中的origin
是否匹配,如果不匹配則報錯。
源origin
我們使用瀏覽器瀏覽網頁的時候,大多數情況都是通過http
請求去訪問對應主機(host
)上的資源(resource
),一般同一個主機同一個埠同一個協議就會被認為是一個源,一般我們會說同協議同域名同埠的請求瀏覽器會認為是同源的請求。可能很多人剛看到這個策略的時候跟我有一樣的想法,為什麼是同一個域名而不是同一個IP
呢?在MDN
的英文文件
裡面寫的是host
也就是主機,要更好的理解什麼是源我們要從伺服器的角度來理解。我們的伺服器上用來處理http
請求的一般是web
伺服器,比如apapce
、nginx
等,在web
伺服器的配置中我們會配置我們的網站域名和根目錄一般預設繫結到80
埠,比如/var/www/html
當web
伺服器接收到http
請求對應目錄的資源的時候就會去我們繫結的目錄搜尋對應的資源。但是一個web
伺服器下面可以繫結多個主機,我們可以用虛擬主機來做域名和目錄的對映,如下
<VirtualHost 127.0.0.1:80> ServerAdmin [email protected] DocumentRoot "E:/server110.com/wordpress-latest" ServerName server110.com ServerAlias www.server110.com ErrorLog "logs/wplatest.com-error.log" CustomLog "logs/server110.com-access.log" combined </VirtualHost> <VirtualHost 127.0.0.2:80> ServerAdmin [email protected] DocumentRoot "E:/server110.com/wordpress-2.9.2" ServerName server110.com ServerAlias www.server110.com ErrorLog "logs/server110.com-error.log" CustomLog "logs/server110.com-access.log" combined </VirtualHost>
當web
伺服器接受到請求的時候會看看是請求頭中的host
引數,在根據配置檔案到對應的目錄尋找資源。正因為這個原因,同源的定義就是same-host
,同一個主機。而web
配置目錄的方法不止虛擬主機一種方式,還可以利用不同的埠進行對映,比如網站a
的目錄/var/www/a
對映到80
埠,而另一個網站b
的目錄/var/www/b
的目錄對映到8080
埠,配置方法就是把上面的配置檔案中的埠改成自己需要的。我們在往上購買的虛擬主機,大部分都是通過這種辦法來配置多個網站的,也就是說這些網站的IP
地址都是相同的,但是他們的擁有者不同,這也就是瀏覽器要對源之間的互動進行限制的原因。最後就是http
和https
,這兩者如果不同,那麼通訊的過程都是不相同的,瀏覽器自然是不允許的,而且一般網站配置了https
那麼所有的資源請求都會是https
,一般不會出現混用。
根據上面的規則我們舉個是否同源的例子,以我的域名https://www.clloz.com
為例,我這個域名解析到了我阿里雲主機的ip
,web
伺服器根據配置檔案可以知道該host
的請求去對應的資料夾取資源,比如有使用者請求https://www.clloz.com/index.html
, 那麼伺服器就會返回這個頁面。如果這個index.html
中的指令碼傳送如下請求,我們可以判斷是否同源:
URL | 結果 | 原因 |
---|---|---|
https://www.clloz.com/study/test.html
|
成功 | 只有路徑不同 |
http://www.clloz.com/test.json
|
失敗 | 協議不同 |
https://www.clloz.com:8080
|
失敗 | 埠不同 |
https://test.clloz.com/test.json
|
失敗 | 域名不同 |
https://clloz.com/test.json
|
失敗 | 域名不同 |
|URL|結果|原因|
主機和域名的區別:一般來說我們申請一個域名是一個二級域名比如clloz.com
(也有認為頂級域和二級域之間還有一級域,阿里雲就是這樣的方式),頂級域名就是就是域名最後的那個部分,比如我們常見的.com
.cn
.org
.edu
等,頂級域名前面一個就是二級域名比如我的域名中的clloz
,以此類推。當我們購買了一個域名以後,我們可以為其新增主機記錄進行解析,比如我可以新增一個www
的主機記錄解析到我的伺服器ip
,也可以新增一個test
主機記錄解析到http://www.clloz.com:8080
,這些添加了主機記錄的能訪問到伺服器上具體檔案的域名就稱為host
主機名,在我們傳送請求的時候,二者可以混用。
為什麼要有同源策略
其實上面解釋源的時候就已經能夠明白為什麼瀏覽器要使用同源策略了,我們來看看文件和歷史。MDN
的解釋如下The same-origin policy is a critical security mechanism that restricts how a document or script loaded from one origin can interact with a resource from another origin. It helps isolate potentially malicious documents, reducing possible attack vectors.
大概意思就是同源策略限制了一個源上的文件或者指令碼和另一個源上的資源互動的方式。主要的目的是為使用者的安全,隔離潛在惡意檔案的重要安全機制。
同源策略最早有網景在1995年引入,現在所有的瀏覽器都實行這個策略。最早同源是為了針對客戶端上儲存狀態的cookie
。為了解決http
協議無狀態帶來的使用者狀態無法儲存的情況引入了cookie
,如果不同源的網站能夠共享cookie
會帶來非常嚴重的安全問題,比如我們登入了某個支付網站或者網上銀行沒有登出,這時候點進了一個危險的網頁,這個網頁可以利用我們的cookie
去登入,這是非常危險的,所以最早的同源策略就是針對這樣的情況,每個源之間的cookie
都是獨立的(父域子域可以共享,後面會說)。但是隨著web
的發展,網站提供的服務越來越多,越來越複雜,也出現了更多的攻擊手段,所以為了安全,瀏覽器不得不提升同源策略覆蓋的範圍。
安全和靈活的矛盾
同源策略確實提高了網站的安全性,讓攻擊者攻擊網站的難度提高,使用者也不會因為誤點惡意連結而遭受損失,但是對於開發者來說,多個子系統之間的互動是必要的,瀏覽器一刀切的同源策略有時候會帶來很大的麻煩,從這方面看安全性和互動的靈活性是一對矛盾。所以瀏覽器在同源策略的制定上還是對互動做了一定的妥協,比如我們都知道的直接用連結嵌入其他源中的css
,js
和image
,父域和子域之間可以共享cookie
等。
跨源互動細節
為了解決跨域導致的跨源互動不便,瀏覽器制定了跨源互動的規則,通常情況下:
1. 允許跨源寫(cross-origin write
),比如我們可以直接在指令碼中發出GET
請求直接跳轉頁面,以及在頁面上直接用submit
按鈕提交表單並跳轉。經過測試,用XMLHttpRequest
物件給後臺傳送檔案也不會被同源策略攔截。
2. 允許跨域資源嵌入:
3. 不允許跨源讀取資源
跨域嵌入的方式:
-
<script src="..."></script>
標籤嵌入跨域指令碼
-
<link rel="stylesheet" href="...">
標籤嵌入CSS
-
<img>
嵌入圖片 -
<video>
和<audio>
嵌入多媒體資源 -
<object>
,<embed>
和<applet>
的外掛。 -
@font-face
引入的字型。一些瀏覽器允許跨域字型(cross-origin fonts
),一些需要同源字型(same-origin fonts
) -
<frame>
和<iframe>
載入的任何資源。站點可以使用X-Frame-Options
訊息頭來阻止這種形式的跨域互動。
瀏覽器的具體同源策略沒有找到標準的文件,不過大致的思路就是我們可以向不同源的傳送資訊,不可以從不同的源接收資訊,我把上面的內容和查到的規則整理如下:
1. 對於嵌入到頁面的ifram
(如果X-Frame-Options
允許),無法訪問iframe
的文件,也就是不能操作DOM
物件。
2.css
檔案可以通過link
標籤嵌入或者@import
方式引入,可能需要Content-type
支援。
3.form
表單,action
可以使用跨源URL
,利用表單的提交可以將表單中的資料寫入跨源目標。
4. 可以用img
標籤嵌入影象,但是無法讀取影象的資料(例如canvas
使用JavaScript
將跨源影象載入到元素中),如果需要讀取影象,需要為圖片所在伺服器開啟cors
,並且為圖片加上屬性crossOrigin=anonymous
,其實是和開啟cors
的ajax
請求沒有區別。CORS_enabled_image
5.可以使用video
和audio
元素嵌入跨源視訊和音訊。
6. 可以嵌入跨源指令碼; 但是,可能會阻止對某些API的訪問,例如跨源的ajax
或者fetch
請求。根據我的測試,用ajax
對跨源介面傳送檔案並不會觸發同源策略,能夠成功傳送。
7. 儲存在瀏覽器中的資料,如localStorage
和IndexedDB
,以源進行分割。每個源都擁有自己單獨的儲存空間,一個源中的Javascript
指令碼不能對屬於其它源的資料進行讀寫操作。
8. 一個頁面可以為本域和任何父域設定cookie
,只要是父域不是公共字尾(public suffix
)即可。
對於嵌入圖片的讀取可以測試如下程式碼:
<!-- 嵌入一張跨域的google logo --> <img crossorigin="anonymous" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" alt=""> <script> function main() { var img = document.querySelector('img'); img.onload = function () { var canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; // Copy the image contents to the canvas var ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); var dataURL = canvas.toDataURL("image/png"); var data = dataURL.replace(/^data:image\/(png|jpg);base64,/, ""); console.log(data); } } main() </script>
執行這個HTML
瀏覽器會告訴你跨域了,解決的方式就是給圖片加上crossorigin="anonymous"
屬性,並且圖片所在伺服器要開啟cors
。
對於ajax
傳送的檔案,大家可以用nodejs
寫一個簡單的服務端,前端用formdata
傳送即可,並不會被瀏覽器攔擊。
跨域方法
同源策略我們已經掌握,但是瀏覽器的這種一刀切的做法有時候會為開發帶來不便。特別是在有多個子系統的網站中,需要跨域通訊的情況肯定會多,我們會把各個子系統佈置在不同的主機上,所以如何饒考同源策略進行跨域請求,是每個前端必須熟練掌握的。
JSONP
JSONP
就是利用同源策略中允許跨域資源嵌入的這條規定來進行跨域請求的,script
標籤請求的指令碼會立即執行,那麼只要請求中傳給後端一個函式名,後端將函式名和資料拼接成執行函式的字串返回給前端,瀏覽器解析的時候就相當於直接執行這個帶引數的函式。
前端程式碼:
<body> <script> function success(data) { console.log(data); } </script> <script src="http://localhost:8080/test?callback=success"></script> </body>
後端程式碼:
var http = require('http') var url = require('url') var routes = { '/test': function (req, res) { var cb_str = url.parse(req.url, true).search res.writeHead(200, 'Ok') var cb = cb_str.split('=')[1] console.log(cb) res.write(cb + `({result: "success"})`) res.end() } } var server = http.createServer(function (req, res) { var pathObj = url.parse(req.url, true) var handleFn = routes[pathObj.pathname] if (handleFn) { console.log(pathObj) handleFn(req, res) } }) server.listen(8080) console.log('server on 8080...')
前端嵌入的script
標籤在請求的時候帶上了函式名success
作為請求引數,後端接收到請求後將前端需要的資料{result: "success"}
連帶函式名拼接成success({result: "success"})
返回給瀏覽器,瀏覽器會直接將返回的字串當作js
執行,由於我們前面已經定義了success
函式,所以這段程式碼會直接給success
函式帶上引數執行,這樣就實現了跨域請求。
JSONP
只能傳送GET
請求
利用form
提交跨域請求
由於form
表單的功能是把資料傳送給對應action
,所以並沒有被同源策略限制,所以我們可以用在指令碼中建立form
並提交的方法來和跨域介面進行通訊,用這種方法我們可以傳送GET
和POST
請求,但是我們沒法接收伺服器返回的資料,不過可以利用設定form
的target
到一個空的iframe
並監聽iframe
的load
事件來確定請求是否傳送成功。
CORS
跨域資源共享(CORS
) 是一種機制,它使用額外的HTTP
頭來告訴瀏覽器 讓執行在一個origin
(domain) 上的Web
應用被准許訪問來自不同源伺服器上的指定的資源。
CORS
需要瀏覽器和伺服器同時支援。目前,所有瀏覽器都支援該功能,IE瀏覽器不能低於IE10
。
整個CORS
通訊過程,都是瀏覽器自動完成,不需要使用者參與。對於開發者來說,CORS
通訊與同源的AJAX通訊沒有差別,程式碼完全一樣。瀏覽器一旦發現AJAX
請求跨源,就會自動新增一些附加的頭資訊,有時還會多出一次附加的請求,但使用者不會有感覺。
因此,實現CORS
通訊的關鍵是伺服器。只要伺服器實現了CORS
介面,就可以跨源通訊。
瀏覽器將CORS
請求分成兩類:簡單請求(simple request
)和非簡單請求(not-so-simple request
)。只要同時滿足以下兩大條件,就屬於簡單請求。
1. 請求方法是以下三種方法之一:
–HEAD
–
GET
–
POST
2.HTTP
的頭資訊不超出以下幾種欄位:
Accept
–
Accept-Language
–
Content-Language
–
Last-Event-ID
–Content-Type
:只限於三個值application/x-www-form-urlencoded
、multipart/form-data
、text/plain
簡單請求
對於簡單請求,前端什麼都不需要做,瀏覽器會自動在我們的請求頭中加一個欄位origin
向後端說明我們的源,伺服器根據這個欄位來決定是否同意該請求,如果Origin
指定的源,不在許可範圍內,伺服器會返回一個正常的HTTP
迴應。瀏覽器發現,這個迴應的頭資訊沒有包含Access-Control-Allow-Origin
欄位,就知道出錯了,從而丟擲一個錯誤,被XMLHttpRequest
的onerror
回撥函式捕獲。注意,這種錯誤無法通過狀態碼識別,因為HTTP迴應的狀態碼有可能是200
。
如果伺服器同意該次跨域請求,那麼在響應頭中會多出以下欄位
1.Access-Control-Allow-Origin
:指定了允許訪問該資源的外域 URI。對於不需要攜帶身份憑證的請求,伺服器可以指定該欄位的值為萬用字元,表示允許來自所有域的請求。
Access-Control-Allow-Credentials
: 該欄位可選。它的值是一個布林值,表示是否允許傳送Cookie
。預設情況下,Cookie
不包括在CORS
請求之中。設為true
,即表示伺服器明確許可,Cookie
可以包含在請求中,一起發給伺服器。這個值也只能設為true
,如果伺服器不要瀏覽器傳送Cookie
,刪除該欄位即可。該欄位為true
的時候,Access-Control-Allow-Origin
必須為一個具體的值,不能設為萬用字元,並且需要前端配合設定xhr.withCredentials = true;
3.Access-Control-Expose-Headers
: 該欄位可選。CORS
請求時,XMLHttpRequest
物件的getResponseHeader()
方法只能拿到6個基本欄位:Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
。如果想拿到其他欄位,就必須在Access-Control-Expose-Headers
裡面指定。如Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
。
簡單請求的前後端示例程式碼如下:
//前端請求 document.cookie = 'name=clloz'; var xhr = new XMLHttpRequest() xhr.open('get', 'http://localhost:8080/test', true) xhr.withCredentials = true; //請求想要傳送cookie必須設定withCreadentials xhr.onload = function () { console.log(xhr.responseText); } xhr.send(); //後端程式碼 var http = require('http') var url = require('url') var querystring = require('querystring'); var util = require('util'); var routes = { '/test': function (req, res) { console.log(req.method) if (req.method === 'GET') { console.log(req.headers.cookie) res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081') res.setHeader('Access-Control-Allow-Credentials', true) //允許前端傳送cookie res.writeHead(200, 'Ok') res.write(`success`) res.end() } } } var server = http.createServer(function (req, res) { var pathObj = url.parse(req.url, true) var handleFn = routes[pathObj.pathname] if (handleFn) { handleFn(req, res) } }) server.listen(8080) console.log('server on 8080...')
非簡單請求
非簡單請求是那種對伺服器有特殊要求的請求,比如請求方法是PUT
或DELETE
,或者Content-Type
欄位的型別是application/json
。
非簡單請求的CORS
請求,會在正式通訊之前,增加一次HTTP
查詢請求,稱為”預檢”請求(preflight
)。瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些HTTP
方法和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的XMLHttpRequest
請求,否則就報錯。
“預檢”請求用的請求方法是OPTIONS
,表示這個請求是用來詢問的。頭資訊裡面,關鍵欄位是Origin
,表示請求來自哪個源。除了Origin
欄位,”預檢”請求的頭資訊包括兩個特殊欄位。
1.Access-Control-Request-Method
:該欄位是必須的,用來列出瀏覽器的CORS請求會用到哪些HTTP方法
2.Access-Control-Request-Headers
:該欄位是一個逗號分隔的字串,指定瀏覽器CORS請求會額外發送的頭資訊欄位
伺服器收到”預檢”請求以後,檢查了Origin
、Access-Control-Request-Method
和Access-Control-Request-Headers
欄位以後,確認允許跨源請求,就可以做出迴應。如果伺服器否定了”預檢”請求,會返回一個正常的HTTP
迴應,但是沒有任何CORS
相關的頭資訊欄位。這時,瀏覽器就會認定,伺服器不同意預檢請求,因此觸發一個錯誤,被XMLHttpRequest
物件的onerror
回撥函式捕獲。控制檯會打印出如下的報錯資訊。通過的預檢請求,伺服器響應頭中會有如下欄位:
1.Access-Control-Allow-Methods
:該欄位必需,它的值是逗號分隔的一個字串,表明伺服器支援的所有跨域請求的方法。注意,返回的是所有支援的方法,而不單是瀏覽器請求的那個方法。這是為了避免多次”預檢”請求。
2.Access-Control-Allow-Headers
:如果瀏覽器請求包括Access-Control-Request-Headers
欄位,則Access-Control-Allow-Headers
欄位是必需的。它也是一個逗號分隔的字串,表明伺服器支援的所有頭資訊欄位,不限於瀏覽器在”預檢”中請求的欄位。
3.Access-Control-Allow-Credentials
: 和簡單請求中相同。
4.Access-Control-Max-Age
: 該欄位可選,用來指定本次預檢請求的有效期,單位為秒。
如果伺服器通過了預檢請求,在有效期內的正常的CORS
請求,就都跟簡單請求一樣,會有一個Origin
頭資訊欄位。伺服器的迴應,也都會有一個Access-Control-Allow-Origin
頭資訊欄位。
非簡單請求的示例程式碼如下:
//前端程式碼 var json = { name: 'clloz', age: '27', sex: 'male' } document.cookie = 'name=clloz'; var xhr = new XMLHttpRequest() xhr.open('post', 'http://localhost:8080/test', true) xhr.setRequestHeader('content-type', 'application/json') xhr.withCredentials = true; xhr.onload = function () { console.log(xhr.responseText); } xhr.send(json); //後端程式碼 var http = require('http') var url = require('url') var querystring = require('querystring'); var util = require('util'); var routes = { '/test': function (req, res) { console.log(req.method) if (req.method === 'GET') { console.log(req.headers.cookie) res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081') res.setHeader('Access-Control-Allow-Credentials', true) res.writeHead(200, 'Ok') res.write(`success`) res.end() } else { var post = ''; req.on('data', function (chunk) { post += chunk; }); req.on('end', function () { res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8081') res.setHeader('Access-Control-Allow-Credentials', true) res.setHeader('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t') res.end('success'); }); } } } var server = http.createServer(function (req, res) { var pathObj = url.parse(req.url, true) var handleFn = routes[pathObj.pathname] if (handleFn) { handleFn(req, res) } }) server.listen(8080) console.log('server on 8080...')
代理
同源策略只是瀏覽器的限制,對於伺服器上的web
伺服器是沒有影響的,所以當我們需要請求跨域資源的時候,可以先向同源的web
伺服器提交請求,由web
伺服器再向對應的伺服器請求到資料後返回給前端。
postMessage
window.postMessage()
方法可以安全地實現跨源通訊。window.postMessage()
方法被呼叫時,會在所有頁面指令碼執行完畢之後(e.g., 在該方法之後設定的事件、之前設定的timeout 事件,etc.)向目標視窗派發一個MessageEvent
訊息。 該MessageEvent
訊息有四個屬性需要注意:message
屬性表示該message
的型別;data
屬性為window.postMessage
的第一個引數;origin
屬性表示呼叫window.postMessage()
方法時呼叫頁面的當前狀態;source
屬性記錄呼叫window.postMessage()
方法的視窗資訊。
用http-server
啟動兩個服務來測試,分別為localhost:8080
和localhost:8081
:
<!-- localhost:8080 --> <body> <button>btn</button> <iframe name="myframe" src="http://localhost:8081" frameborder="1"></iframe> <script> window.addEventListener('message', function (e) { if (e.origin === 'http://localhost:8081') { console.log(e.data) } }) var iframe = window.frames['myframe'] var btn = document.querySelector('button') btn.addEventListener('click', function () { iframe.postMessage('this is 8080', 'http://localhost:8081') }) </script> </body> <!-- localhost:8081 --> <body> this is frame! <script> window.addEventListener("message", function(e) { if (e.origin === "http://localhost:8080") { console.log(e.data); e.source.postMessage("this is 8081", e.origin); } }); </script> </body>
點選第一個頁面的按鈕,會向第二頁面傳送訊息,第二個頁面收到訊息會立即返回。
window.domain
2.document.domain
這種方式只適合主域名相同,但子域名不同的iframe跨域。
比如主域名是http://clloz.com
,子域名是http://test.crossdomain.com
,這種情況下給兩個頁面指定一下document.domain
即document.domain = clloz.com
就可以訪問各自的window
物件了。