# 同源限制
瀏覽器安全的基石是“同源政策”([same-origin policy](https://en.wikipedia.org/wiki/Same-origin_policy))。很多開發者都知道這一點,但了解得不全面。
## 概述
### 含義
1995年,同源政策由 Netscape 公司引入瀏覽器。目前,所有瀏覽器都實行這個政策。
最初,它的含義是指,A 網頁設置的 Cookie,B 網頁不能打開,除非這兩個網頁“同源”。所謂“同源”指的是“三個相同”。
> - 協議相同
> - 域名相同
> - 端口相同(這點可以忽略,詳見下文)
舉例來說,`http://www.example.com/dir/page.html`這個網址,協議是`http://`,域名是`www.example.com`,端口是`80`(默認端口可以省略),它的同源情況如下。
- `http://www.example.com/dir2/other.html`:同源
- `http://example.com/dir/other.html`:不同源(域名不同)
- `http://v2.www.example.com/dir/other.html`:不同源(域名不同)
- `http://www.example.com:81/dir/other.html`:不同源(端口不同)
- `https://www.example.com/dir/page.html`:不同源(協議不同)
注意,標準規定端口不同的網址不是同源(比如8000端口和8001端口不是同源),但是瀏覽器沒有遵守這條規定。實際上,同一個網域的不同端口,是可以互相讀取 Cookie 的。
### 目的
同源政策的目的,是為了保證用戶信息的安全,防止惡意的網站竊取數據。
設想這樣一種情況:A 網站是一家銀行,用戶登錄以后,A 網站在用戶的機器上設置了一個 Cookie,包含了一些隱私信息。用戶離開 A 網站以后,又去訪問 B 網站,如果沒有同源限制,B 網站可以讀取 A 網站的 Cookie,那么隱私就泄漏了。更可怕的是,Cookie 往往用來保存用戶的登錄狀態,如果用戶沒有退出登錄,其他網站就可以冒充用戶,為所欲為。因為瀏覽器同時還規定,提交表單不受同源政策的限制。
由此可見,同源政策是必需的,否則 Cookie 可以共享,互聯網就毫無安全可言了。
### 限制范圍
隨著互聯網的發展,同源政策越來越嚴格。目前,如果非同源,共有三種行為受到限制。
> (1) 無法讀取非同源網頁的 Cookie、LocalStorage 和 IndexedDB。
>
> (2) 無法接觸非同源網頁的 DOM。
>
> (3) 無法向非同源地址發送 AJAX 請求(可以發送,但瀏覽器會拒絕接受響應)。
另外,通過 JavaScript 腳本可以拿到其他窗口的`window`對象。如果是非同源的網頁,目前允許一個窗口可以接觸其他網頁的`window`對象的九個屬性和四個方法。
- window.closed
- window.frames
- window.length
- window.location
- window.opener
- window.parent
- window.self
- window.top
- window.window
- window.blur()
- window.close()
- window.focus()
- window.postMessage()
上面的九個屬性之中,只有`window.location`是可讀寫的,其他八個全部都是只讀。而且,即使是`location`對象,非同源的情況下,也只允許調用`location.replace()`方法和寫入`location.href`屬性。
雖然這些限制是必要的,但是有時很不方便,合理的用途也受到影響。下面介紹如何規避上面的限制。
## Cookie
Cookie 是服務器寫入瀏覽器的一小段信息,只有同源的網頁才能共享。如果兩個網頁一級域名相同,只是次級域名不同,瀏覽器允許通過設置`document.domain`共享 Cookie。
舉例來說,A 網頁的網址是`http://w1.example.com/a.html`,B 網頁的網址是`http://w2.example.com/b.html`,那么只要設置相同的`document.domain`,兩個網頁就可以共享 Cookie。因為瀏覽器通過`document.domain`屬性來檢查是否同源。
```javascript
// 兩個網頁都需要設置
document.domain = 'example.com';
```
注意,A 和 B 兩個網頁都需要設置`document.domain`屬性,才能達到同源的目的。因為設置`document.domain`的同時,會把端口重置為`null`,因此如果只設置一個網頁的`document.domain`,會導致兩個網址的端口不同,還是達不到同源的目的。
現在,A 網頁通過腳本設置一個 Cookie。
```javascript
document.cookie = "test1=hello";
```
B 網頁就可以讀到這個 Cookie。
```javascript
var allCookie = document.cookie;
```
注意,這種方法只適用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexedDB 無法通過這種方法,規避同源政策,而要使用下文介紹 PostMessage API。
另外,服務器也可以在設置 Cookie 的時候,指定 Cookie 的所屬域名為一級域名,比如`.example.com`。
```http
Set-Cookie: key=value; domain=.example.com; path=/
```
這樣的話,二級域名和三級域名不用做任何設置,都可以讀取這個 Cookie。
## iframe 和多窗口通信
`iframe`元素可以在當前網頁之中,嵌入其他網頁。每個`iframe`元素形成自己的窗口,即有自己的`window`對象。`iframe`窗口之中的腳本,可以獲得父窗口和子窗口。但是,只有在同源的情況下,父窗口和子窗口才能通信;如果跨域,就無法拿到對方的 DOM。
比如,父窗口運行下面的命令,如果`iframe`窗口不是同源,就會報錯。
```javascript
document
.getElementById("myIFrame")
.contentWindow
.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.
```
上面命令中,父窗口想獲取子窗口的 DOM,因為跨域導致報錯。
反之亦然,子窗口獲取主窗口的 DOM 也會報錯。
```javascript
window.parent.document.body
// 報錯
```
這種情況不僅適用于`iframe`窗口,還適用于`window.open`方法打開的窗口,只要跨域,父窗口與子窗口之間就無法通信。
如果兩個窗口一級域名相同,只是二級域名不同,那么設置上一節介紹的`document.domain`屬性,就可以規避同源政策,拿到 DOM。
對于完全不同源的網站,目前有兩種方法,可以解決跨域窗口的通信問題。
> - 片段識別符(fragment identifier)
> - 跨文檔通信API(Cross-document messaging)
### 片段識別符
片段標識符(fragment identifier)指的是,URL 的`#`號后面的部分,比如`http://example.com/x.html#fragment`的`#fragment`。如果只是改變片段標識符,頁面不會重新刷新。
父窗口可以把信息,寫入子窗口的片段標識符。
```javascript
var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;
```
上面代碼中,父窗口把所要傳遞的信息,寫入 iframe 窗口的片段標識符。
子窗口通過監聽`hashchange`事件得到通知。
```javascript
window.onhashchange = checkMessage;
function checkMessage() {
var message = window.location.hash;
// ...
}
```
同樣的,子窗口也可以改變父窗口的片段標識符。
```javascript
parent.location.href = target + '#' + hash;
```
### window.postMessage()
上面的這種方法屬于破解,HTML5 為了解決這個問題,引入了一個全新的API:跨文檔通信 API(Cross-document messaging)。
這個 API 為`window`對象新增了一個`window.postMessage`方法,允許跨窗口通信,不論這兩個窗口是否同源。舉例來說,父窗口`aaa.com`向子窗口`bbb.com`發消息,調用`postMessage`方法就可以了。
```javascript
// 父窗口打開一個子窗口
var popup = window.open('http://bbb.com', 'title');
// 父窗口向子窗口發消息
popup.postMessage('Hello World!', 'http://bbb.com');
```
`postMessage`方法的第一個參數是具體的信息內容,第二個參數是接收消息的窗口的源(origin),即“協議 + 域名 + 端口”。也可以設為`*`,表示不限制域名,向所有窗口發送。
子窗口向父窗口發送消息的寫法類似。
```javascript
// 子窗口向父窗口發消息
window.opener.postMessage('Nice to see you', 'http://aaa.com');
```
父窗口和子窗口都可以通過`message`事件,監聽對方的消息。
```javascript
// 父窗口和子窗口都可以用下面的代碼,
// 監聽 message 消息
window.addEventListener('message', function (e) {
console.log(e.data);
},false);
```
`message`事件的參數是事件對象`event`,提供以下三個屬性。
> - `event.source`:發送消息的窗口
> - `event.origin`: 消息發向的網址
> - `event.data`: 消息內容
下面的例子是,子窗口通過`event.source`屬性引用父窗口,然后發送消息。
```javascript
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}
```
上面代碼有幾個地方需要注意。首先,`receiveMessage`函數里面沒有過濾信息的來源,任意網址發來的信息都會被處理。其次,`postMessage`方法中指定的目標窗口的網址是一個星號,表示該信息可以向任意網址發送。通常來說,這兩種做法是不推薦的,因為不夠安全,可能會被惡意利用。
`event.origin`屬性可以過濾不是發給本窗口的消息。
```javascript
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}
```
### LocalStorage
通過`window.postMessage`,讀寫其他窗口的 LocalStorage 也成為了可能。
下面是一個例子,主窗口寫入 iframe 子窗口的`localStorage`。
```javascript
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') {
return;
}
var payload = JSON.parse(e.data);
localStorage.setItem(payload.key, JSON.stringify(payload.data));
};
```
上面代碼中,子窗口將父窗口發來的消息,寫入自己的 LocalStorage。
父窗口發送消息的代碼如下。
```javascript
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(
JSON.stringify({key: 'storage', data: obj}),
'http://bbb.com'
);
```
加強版的子窗口接收消息的代碼如下。
```javascript
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') return;
var payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
var parent = window.parent;
var data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};
```
加強版的父窗口發送消息代碼如下。
```javascript
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
// 存入對象
win.postMessage(
JSON.stringify({key: 'storage', method: 'set', data: obj}),
'http://bbb.com'
);
// 讀取對象
win.postMessage(
JSON.stringify({key: 'storage', method: "get"}),
"*"
);
window.onmessage = function(e) {
if (e.origin != 'http://aaa.com') return;
console.log(JSON.parse(e.data).name);
};
```
## AJAX
同源政策規定,AJAX 請求只能發給同源的網址,否則就報錯。
除了架設服務器代理(瀏覽器請求同源服務器,再由后者請求外部服務),有三種方法規避這個限制。
> - JSONP
> - WebSocket
> - CORS
### JSONP
JSONP 是服務器與客戶端跨源通信的常用方法。最大特點就是簡單易用,沒有兼容性問題,老式瀏覽器全部支持,服務端改造非常小。
它的做法如下。
第一步,網頁添加一個`<script>`元素,向服務器請求一個腳本,這不受同源政策限制,可以跨域請求。
```html
<script src="http://api.foo.com?callback=bar"></script>
```
注意,請求的腳本網址有一個`callback`參數(`?callback=bar`),用來告訴服務器,客戶端的回調函數名稱(`bar`)。
第二步,服務器收到請求后,拼接一個字符串,將 JSON 數據放在函數名里面,作為字符串返回(`bar({...})`)。
第三步,客戶端會將服務器返回的字符串,作為代碼解析,因為瀏覽器認為,這是`<script>`標簽請求的腳本內容。這時,客戶端只要定義了`bar()`函數,就能在該函數體內,拿到服務器返回的 JSON 數據。
下面看一個實例。首先,網頁動態插入`<script>`元素,由它向跨域網址發出請求。
```javascript
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};
```
上面代碼通過動態添加`<script>`元素,向服務器`example.com`發出請求。注意,該請求的查詢字符串有一個`callback`參數,用來指定回調函數的名字,這對于 JSONP 是必需的。
服務器收到這個請求以后,會將數據放在回調函數的參數位置返回。
```javascript
foo({
'ip': '8.8.8.8'
});
```
由于`<script>`元素請求的腳本,直接作為代碼運行。這時,只要瀏覽器定義了`foo`函數,該函數就會立即調用。作為參數的 JSON 數據被視為 JavaScript 對象,而不是字符串,因此避免了使用`JSON.parse`的步驟。
### WebSocket
WebSocket 是一種通信協議,使用`ws://`(非加密)和`wss://`(加密)作為協議前綴。該協議不實行同源政策,只要服務器支持,就可以通過它進行跨源通信。
下面是一個例子,瀏覽器發出的 WebSocket 請求的頭信息(摘自[維基百科](https://en.wikipedia.org/wiki/WebSocket))。
```http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
```
上面代碼中,有一個字段是`Origin`,表示該請求的請求源(origin),即發自哪個域名。
正是因為有了`Origin`這個字段,所以 WebSocket 才沒有實行同源政策。因為服務器可以根據這個字段,判斷是否許可本次通信。如果該域名在白名單內,服務器就會做出如下回應。
```http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
```
### CORS
CORS 是跨源資源分享(Cross-Origin Resource Sharing)的縮寫。它是 W3C 標準,屬于跨源 AJAX 請求的根本解決方法。相比 JSONP 只能發`GET`請求,CORS 允許任何類型的請求。
下一章將詳細介紹,如何通過 CORS 完成跨源 AJAX 請求。
## 參考鏈接
- Mozilla Developer Network, [Window.postMessage](https://developer.mozilla.org/en-US/docs/Web/API/window.postMessage)
- Jakub Jankiewicz, [Cross-Domain LocalStorage](http://jcubic.wordpress.com/2014/06/20/cross-domain-localstorage/)
- David Baron, [setTimeout with a shorter delay](http://dbaron.org/log/20100309-faster-timeouts): 利用 window.postMessage 可以實現0毫秒觸發回調函數
- 前言
- 入門篇
- 導論
- 歷史
- 基本語法
- 數據類型
- 概述
- null,undefined 和布爾值
- 數值
- 字符串
- 對象
- 函數
- 數組
- 運算符
- 算術運算符
- 比較運算符
- 布爾運算符
- 二進制位運算符
- 其他運算符,運算順序
- 語法專題
- 數據類型的轉換
- 錯誤處理機制
- 編程風格
- console 對象與控制臺
- 標準庫
- Object 對象
- 屬性描述對象
- Array 對象
- 包裝對象
- Boolean 對象
- Number 對象
- String 對象
- Math 對象
- Date 對象
- RegExp 對象
- JSON 對象
- 面向對象編程
- 實例對象與 new 命令
- this 關鍵字
- 對象的繼承
- Object 對象的相關方法
- 嚴格模式
- 異步操作
- 概述
- 定時器
- Promise 對象
- DOM
- 概述
- Node 接口
- NodeList 接口,HTMLCollection 接口
- ParentNode 接口,ChildNode 接口
- Document 節點
- Element 節點
- 屬性的操作
- Text 節點和 DocumentFragment 節點
- CSS 操作
- Mutation Observer API
- 事件
- EventTarget 接口
- 事件模型
- Event 對象
- 鼠標事件
- 鍵盤事件
- 進度事件
- 表單事件
- 觸摸事件
- 拖拉事件
- 其他常見事件
- GlobalEventHandlers 接口
- 瀏覽器模型
- 瀏覽器模型概述
- window 對象
- Navigator 對象,Screen 對象
- Cookie
- XMLHttpRequest 對象
- 同源限制
- CORS 通信
- Storage 接口
- History 對象
- Location 對象,URL 對象,URLSearchParams 對象
- ArrayBuffer 對象,Blob 對象
- File 對象,FileList 對象,FileReader 對象
- 表單,FormData 對象
- IndexedDB API
- Web Worker
- 附錄:網頁元素接口
- a
- img
- form
- input
- button
- option
- video,audio