前言

「同源政策」(same-origin policy)是由 Netscape 公司引入瀏覽器的。其使得瀏覽器更加安全,現在所有瀏覽器都支持了這個策略。然而,安全的同時也使得開發過程中出現了一些麻煩。本文列出了一些常見的同源策略問題以及相對應的解決方案。

常見問題

「同源」指的是兩個地址之間協議相同域名相同端口相同,只要有一個不相同就會出現問題。

目前,受到「同源政策」限製的有以下功能:

  • Cookie、LocalStorage、IndexDB 的讀取;
  • AJAX 請求的響應被瀏覽器拒絕(瀏覽器雖然收到了響應,但是並不使用);
  • DOM 無法獲取(更不用說操作了)。

Cookie 是由服務器寫入瀏覽器的,用來控製客戶端的狀態。雖然受到「同源政策」限製,但是並不苛刻。同源的頁面之間可以共享 Cookie,一級域名相同二級域名不同的頁面之間也可以通過設置 document.domain 共享 Cookie。

註意:該方法僅適用於 Cookie、iframe 窗口,LocalStorage、IndexDB 無法使用該方法解決「同源政策」問題。

iframe

iframe 標簽使得一個頁面中可以嵌入另一個頁面,如果兩個頁面不同源,就無法拿到對方的 DOM。這樣會造成父子頁面無法通信。

對於不同源的父子頁面有以下方法解決通信問題:

  1. 動態 hash
  2. window.name
  3. window.postMessage

動態 hash

在父頁面中,通過使用<iframe src="http://example.com/XXX.html#message"></iframe>來嵌入子頁面。其中,http://example.com/XXX.html 為子頁面地址,# 後面的部分稱之為片段標識符(Fragment Identifier),片段標識符改變頁面不會重新加載,父頁面傳遞給子頁面的數據可以放到此部分內。

其過程是父頁面動態修改 iframe 標簽中 src 屬性值的片段標識符部分,子頁面通過 window.onhashchange 事件監聽片段標識符部分的變化。

1
2
3
4
// 父頁面代碼
document.querySelector('iframe').src = url + '#' + message
// url為子頁面地址
// message變量存放父頁面傳遞給子頁面的數據
1
2
3
4
5
// 子頁面代碼

window.onhashchange = () => {
const message = window.location.hash
}

同理,該方法也可以逆向使用,即實現子頁面向父頁面傳遞數據。

1
2
3
4
// 父頁面代碼
window.onhashchange = () => {
const message = window.location.hash
}
1
2
3
4
// 子頁面代碼
parent.location.href = url + '#' + message
// url為父頁面地址
// message變量存放子頁面傳遞給父頁面的數據

window.name

window.name 是一個特殊的屬性。無論頁面之間是否同源,只要在同一個窗口內,共享該屬性值(前一個頁面設置了該屬性,後一個頁面就可以獲取它。)。

window.postMessage

這是 HTML5 新增加的 API,無論兩個窗口是否同源,都可以進行跨窗口通信。

1
2
3
4
5
6
// 父頁面向子頁面發送消息
const popupPage = window.open('http://father.com')

popupPage.postMessage('message', 'http://son.com')
// message是向子頁面發送的消息 http://son.com是接收消息的子頁面
// postMessage第二個參數為*時不限製域名,向所有窗口發送
1
2
// 子頁面向父頁面發送消息
window.opener.postMessage('message', 'http://father.com')
1
2
3
4
5
6
7
8
9
// 父頁面和子頁面都可以使用message事件監聽對方的消息
window.addEventListener('message', (e) => {
console.log(e.data) // 接收到的數據內容
console.log(e.source) // 發送消息的頁面地址
console.log(e.origin) // 接收消息的頁面地址

e.source.postMessage('message', '*')
// 子頁面通過e.source屬性引用父頁面並發送消息
})

因此,通過 window.postMessage 可以間接讀寫其他頁面的 LocalStorage、IndexDB。

參考鏈接:https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage

AJAX

由於同源政策的影響,AJAX 請求只能發給同源的網址,否則報錯。解決方法有 JSONP、iframe、postMessage、window.name、代理服務器、CORS、WebSocket。

JSONP

該方法是瀏覽器與服務器跨源通信的常用方法,對於前端簡單且兼容性好,對於後端服務器變動小。其原理是 scriptimg 等標簽中的 src 屬性請求非同源鏈接不受同源策略影響。

1
2
3
4
5
6
7
8
9
10
11
const getData = (data) => {
console.log(data)
}

// 動態添加script標簽
// 向跨源網址發出請求
let script = document.createElement('script')
script.setAttribute('type', 'text/javascript')
script.src = 'http://example.com/?callback=getData'
// callback參數用來指定回調函數的名字 服務器收到這個請求以後將數據放在回調函數的參數位置後一起返回
document.body.appendChild(script)

CORS

CORS 是跨域資源分享(Cross-Origin Resource Sharing)的縮寫。它使得瀏覽器能夠向非同源服務器發出 AJAX 請求,解決了 AJAX 請求只能向同源服務器發出請求的限製。

(圖片來自網絡)

1.簡單非同源請求:

瀏覽器如果請求非同源服務器,會自動在請求頭中添加 Origin(綠圈標註部分)字段,用來說明本次請求來自哪個源。服務器會根據這個字段值,決定是否同意這次非同源請求。如果 Origin 指定的源不在許可範圍內,服務器會返回一個常規的 HTTP 響應。瀏覽器檢測響應頭,發現沒有包含 Access-Control-Allow-Origin(紅圈標註部分)字段,此時瀏覽器會拋出一個錯誤,請求失敗,此時狀態碼仍有可能是 200,響應數據雖然返回給了瀏覽器,但是被瀏覽器攔截了,無法在請求回調函數中獲取。相反,如果 Origin 指定的源在許可範圍內,則響應頭中包含 Access-Control-Allow-Origin 字段,可以收到數據。

  • Access-Control-Allow-Origin:該字段取值要麽是請求頭中 Origin 字段的值,要麽是一個星號(表示允許來自所有域的跨域請求)。

(圖片來自網絡)

2.含有「預檢請求」的非同源請求:

這種請求會在正式請求發出之前提前發出一次 HTTP 查詢請求(稱之為「預檢請求」)。預檢請求是瀏覽器預先詢問服務器,當前頁面的域名是否在服務器的允許名單之中,以及可以使用哪些 HTTP 請求方法請求頭字段。當提出的要求被服務器完全「同意」後,瀏覽器才會發出真正的非同源請求,否則無法進行下一步或者報錯。

「預檢請求」使用的請求方法是 OPTIONS(紫圈標註部分),用來告訴服務器該請求是「預檢請求」,不是真正的請求。此外,”預檢請求”的請求頭中還包含 Access-Control-Request-MethodAccess-Control-Request-Headers(黃圈標註部分)兩個特殊字段。

  • Access-Control-Request-Method:該字段是瀏覽器用來告訴服務器接下來的正式請求將要使用哪些 HTTP 請求方法。
  • Access-Control-Request-Headers:該字段取值是一個逗號分隔的字符串。該字段是瀏覽器用來告訴服務器接下來的正式請求在請求頭中將要附加額外的請求頭字段。

與此同時,服務器對「預檢請求」做出響應,在響應頭中包含 Access-Control-Allow-MethodsAccess-Control-Allow-Headers(紅圈標註部分)等字段。

  • Access-Control-Allow-Methods:該字段取值是一個逗號分隔的字符串。表明服務器支持的所有跨域請求的方法(避免多次「預檢請求」)。
  • Access-Control-Allow-Headers:該字段用來指定接下來的正式請求允許攜帶的請求頭字段。
  • Access-Control-Max-Age:該字段用來指定本次預檢請求的有效期(有效期內不用再發送預檢請求),單位為秒。
  • Access-Control-Expose-Headers:該字段取值是一個逗號分隔的字符串。在跨域請求時,XMLHttpRequest 對象的 getResponseHeader 方法只能拿到一些最基本的響應頭字段(Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma)。如果想拿到其他字段,就必須在 Access-Control-Expose-Headers 裏面指定。

(圖片來自網絡)

滿足以下條件之一的請求在正式請求發出之前會提前發出「預檢請求」:

  • 一個請求在請求頭中包含了任何自定義請求頭字段。
  • 使用 HTTP 請求方式是 GETHEADPOST 之外的任何一種方式。
  • 請求方式是 POST,但請求頭的 Content-Type 字段取值是 application/x-www-form-urlencodedmultipart/form-datatext/plain 之外的。
1
2
3
4
5
6
const url = 'http://example.com/'
const xhr = new XMLHttpRequest()

xhr.open('PUT', url, true)
xhr.setRequestHeader('X-Custom-Header', 'value')
xhr.send()

上面的 XMLHttpRequest 請求中,請求方法是 PUT,不是常規請求方式。且在生成的請求頭中,有一個用戶自定義的字段 X-Custom-Header,瀏覽器發現這是個非正常字段自動發出一個”預檢請求”,詢問服務器是否可以接受這個非正常請求頭字段。如果服務器回絕則返回一個正常的響應頭,此時瀏覽器報錯;如果同意,則返回的響應頭中會有 Access-Control-Allow-Headers: X-Custom-Header

3.附帶身份憑證的非同源請求:

該請求和前兩種請求類似,只不過在發送請求的時候,需要將用戶憑證包含在請求中。

默認情況下,Cookie 不會包含在非同源請求之中,開發者需要在非同源請求中把 withCredentials 屬性的值設置為 true,而且服務器響應頭中的 Access-Control-Allow-Credentials 字段取值也要設置為 true,兩者缺一不可。只有這樣,瀏覽器才會把響應內容返回給請求的發起者。對於這種請求,響應頭中的 Access-Control-Allow-Origin 字段取值不能為星號。這是因為請求頭中攜帶了 Cookie 信息,如果 Access-Control-Allow-Origin 取值為星號請求將會失敗,而將 Access-Control-Allow-Origin 字段的值設置為請求發起者所在域名則請求成功執行。此外,這時響應頭中也攜帶了 Set-Cookie 字段。

(圖片來自網絡)

1
2
3
4
5
6
7
const url = 'http://example.com/'
const xhr = new XMLHttpRequest()

xhr.open('GET', url, true)
xhr.withCredentials = true
// XMLHttpRequest的withCredentials設置為true從而向服務器發送Cookie
xhr.send()

註意:有的瀏覽器不用設置 withCredentialstrue 也可以向服務器發送 Cookie,可以通過 xhr.withCredentials = false 顯式關閉。