全解跨域請求處理辦法
為什麼會有跨域問題
我們試想一下以下幾種情況:
- 我們打開了一個天貓並且登入了自己的賬號,這時我們再開啟一個天貓的商品,我們不需要再進行一次登入就可以直接購買商品,因為這兩個網頁是同源的,可以共享登入相關的 cookie 或 localStorage 資料;
- 如果你正在用支付寶或者網銀,同時打開了一個不知名的網頁,如果這個網頁可以訪問你支付寶或者網銀頁面的資訊,就會產生嚴重的安全的問題。如果該未知網站是黑客的工具,那他就可以藉此發起 CSRF 攻擊了。顯然瀏覽器不允許這樣的事情發生;
- 想必你也有過同時登陸好幾個 qq 賬號的情況,如果同時開啟各自的 qq 空間瀏覽器會有一個小號模式,也就是另外再開啟一個視窗專門用來開啟第二個 qq 賬號的空間。
為了解決不同域名相互訪問資料導致的不安全問題,Netscape提出的一個著名的安全策略——同源策略,它是指同一個“源頭”的資料可以自由訪問,但不同源的資料相互之間都不能訪問。
同源策略
很明顯,上述第1個和第3個例子中,不同的天貓商店和 qq 空間屬於同源,可以共享登入資訊。qq 為了區別不同的 qq 的登入資訊,重新打開了一個視窗,因為瀏覽器的不同視窗是不能共享資訊的。而第2個例子中的支付寶、網銀、不知名網站之間是非同源的,所以彼此之間無法訪問資訊,如果你執意想請求資料,會提示異常:
No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access.
那麼什麼是同源的請求呢?同源請求要求被請求資源頁面和發出請求頁面滿足3個相同:
協議相同
host相同
埠相同
簡單理解一下:
/*以下兩個資料非同源,因為協議不同*/ http://www.abc123.com.cn/item/a.js https://www.abc123.com.cn/item/a.js /*以下兩個資料非同源,因為域名不同*/ http://www.abc123.com.cn/item/a.js http://www.abc123.com/item/a.js /*以下兩個資料非同源,因為主機名不同*/ http://www.abc123.com.cn/item/a.js http://item.abc123.com.cn/item/a.js /*以下兩個資料非同源,因為協議不同*/ http://www.abc123.com.cn/item/a.js http://www.abc123.com.cn:8080/item/a.js /* 以下兩個資料非同源,域名和 ip 視為不同源 * 這裡應注意,ip和域名替換一樣不是同源的 * 假設www.abc123.com.cn解析後的 ip 是 195.155.200.134 */ http://www.abc123.com.cn/ http://195.155.200.134/ /*以下兩個資料同源*/ /* 這個是同源的*/ http://www.abc123.com.cn/source/a.html http://www.abc123.com.cn/item/b.js
HTTP 簡單請求和非簡單請求
http 請求滿足一下條件時稱為簡單請求,否則是非簡單請求:
- 請求方法是 HEAD,GET,POST 之一
-
HTTP的頭資訊不超出以下幾種欄位:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type
- Content-Type 取值僅限於
application/x-www-form-urlencoded
,multipart/form-data
,text/plain
非簡單請求在傳送之前會發送一次 OPTION 預請求,如果在跨域操作遇到返回 405(Method Not Allowed) 錯誤,需要服務端允許 OPTION 請求。
HTTP 跨域訪問的處理辦法及適用條件
JSOP
適用條件:請求的 GET 介面需要支援 jsonp 訪問
這裡需要強調的是,jsonp 不屬於 Ajax 的部分,它只是把 url 放入 script 標籤中實現的資料傳輸,不受同源策略限制。由於一般庫也會把它和 Ajax 封裝在一起,由於其和 Ajax 根部不是一回事,所以這裡不討論。下面是一個 jsonp 的例子:
window.jsonpCallback = console.log;
var JSONP = document.createElement("script");
JSONP.src = "http://tcc.taobao.com/cc/json/mobile_tel_segment.htm?tel=13122222222&t=" + Math.random() + "&callback=jsonpCallback";;
document.body.appendChild(JSONP);
後端支援jsonp方式(Nodejs)
var querystring = require('querystring');
var http = require('http');
var server = http.createServer();
server.on('request', function(req, res) {
var params = qs.parse(req.url.split('?')[1]);
var fn = params.callback;
// jsonp返回設定
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.write(fn + '(' + JSON.stringify(params) + ')');
res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
document.domain
適用條件: host 中僅伺服器不同的情況,域名本身應該相同
www.dom.com
和 w1.dom.com
需要同源才能訪問,可以將 document.domain 設定為 dom.com
解決該問題
document.domain = 'dom.com';
例如,我想開發一個瀏覽器外掛,發現騰訊視訊頁有個 iframe 其本身的跨域的,無法獲取其 iframe 的 DOM 物件。但域名部分相同,可以通過該方法解決.
注:如果你想設定它為完全不同的域名,那肯定會報同源錯誤的,注意使用範圍!
嵌入 iframe
適用條件: host 中僅伺服器不同的情況,域名本身應該相同
有了上面的例子就不難理解這個方法了,嚴格來說這不是一個新的方法,而是上一個方法的延伸。通過設定document.domain
, 使同一個域名下不同伺服器名的頁面可以訪問資料,但值得注意的是:這個資料訪問不是相互的,外部頁面可以訪問 iframe 內部的資料,但 iframe 無法不能訪問外部的資料。
location.hash
適用條件:iframe 和其宿主頁面通訊
一個完成的 url 中 # 及後面的部分為 hash, 可以通過修改這個部分完成iframe 的和宿主直接的資料傳遞,下面演示一下 iframe 頁面(B.html)像宿主(A.html)傳資料, 反之同理:
// A.html
data = ['book', 'map', 'shelf', 'knife'];
setTimeout(() => {
location.hash = window.encodeURIComponent(data.join('/'));
}, 1000);
// B.html
window.parent.onhashchange = function (e) {
var data = window.decodeURIComponent(e.newURL.split('#')[1]).split('/');
console.log(data); // ["book", "map", "shelf", "knife"]
}
*注意反向傳遞資料時應該使用 window.parent.location.hash
window.name
適用條件:宿主頁面和 iframe 之間通訊
window物件有個name屬性,該屬性有個特徵:即在 window 的生命週期內,視窗載入的所有的頁面 (iframe) 都是共享一個 window.name
的,每個頁面對 window.name
都有讀寫的許可權,window.name
是持久存在一個視窗載入過的所有頁面中的,並不會因新頁面的載入而進行重置。
這樣在 window 中編輯 window.name 就可以在 iframe 中得到,但這個過程缺乏監聽,宿主頁面(A.html)和 iframe 頁面(B.html)相互並不知道對方在什麼時候修改該值:
// A.html
setTimeout(() => {
window.parent.name = "what!";
}, 2000);
// B.html
setTimeout(() => {
console.log(window.name); // what!
}, 2500);
postMessage
適用條件:postMessage 是 H5 提出的一個訊息互通的機制,解決 iframe 不能訊息互通的問題,也可以跨 window 通訊,語法如下:
// 在 www.siteA.com 中發出訊息
// @message{any} 要傳送的資料(注意:老版本瀏覽器只支援字串型別)
// @targetOrigin{string} 規定接收資料的域,只有其指定的域才能收到訊息,如果為"*"則沒用域的限制
// transfer{any} 與 message 一同傳送並轉移所有權
window.postMessage(message, targetOrigin, [transfer]);
// 在另一個頁面接受引數
window.onmessage = console.log;
這裡暫不談論第三個引數,因為你可能一輩子也用不到它。而 targetOrigin 最好不要使用 "*",除非你想讓所有頁面都收到你的訊息。
一種你會用到的場景(iframe):
<!-- www.siteA.com/index.html -->
<script>
window.addEventListener('message', function(e){
console.log('Get message: "' + e.data.title + '" from ' + e.origin); // 'Get message: "Saying hello to siteA!" from http://www.siteB.com'
});
</script>
<iframe src="http://www.siteB.com"></iframe>
<!-- www.siteB.com/index.html -->
<script>
function sendMessage(){
window.postMessage({title: 'Saying hello to siteA!'}, 'http://www.siteA.com');
}
setTimeout(sendMessage, 2000);
</script>
這一種僅僅是沒有了iframe,當你在同一個瀏覽器視窗同時開啟 www.siteA.com
和 www.siteB.com
兩個標籤時也可以這樣用
<!-- www.siteA.com/index.html -->
<script>
window.addEventListener('message', function(e){
console.log('Get message: "' + e.data.title + '" from ' + e.origin); // 'Get message: "Saying hello to siteA!" from http://www.siteB.com'
});
</script>
<!-- www.siteB.com/index.html -->
<script>
function sendMessage(){
window.postMessage({title: 'Saying hello to siteA!'}, 'http://www.siteA.com');
}
setTimeout(sendMessage, 2000);
</script>
反向代理伺服器
頁面需要訪問一些跨域介面,由於代理的存在,在伺服器看來請求是不跨域,所以使用各種請求。但需要注意 http 到 https 的相容問題。
比如當我在一些線上平臺開發網站後得到一個頁面 www.site-A.com
, 而這個頁面需要請求我自己的資料伺服器data.site-B.com
上的資料, 這樣同樣會產生跨域問題,但是www.site-A.com
這個頁面是掛在第三方伺服器上的,解決這個問題可以採用代理伺服器的方法:
var express = require('express');
var request = require('request');
var app = express();
app.use('/api', function(req, res) {
var url = 'http://data.site-B.com/api2' + req.url;
req.pipe(request(url)).pipe(res);
});
app.use('/', function(req, res) {
var url = 'http://data.site-C.com';
req.pipe(request(url)).pipe(res);
});
當然還需要同時配置一個 host:
127.0.0.1 local.www.site-B.com
然後訪問 local.www.site-B.com 就 OK 了。
CORS
適用條件:CORS 需要服務端支援,且存在一定的相容性問題(如今你已經可以不考慮,但必要時不要忘了這個'bug')。其通過新增 http 頭關鍵字實現跨域可訪問,包括如下頭內容:
# www.siteA.com/api 返回相應需要具有如下 http 頭欄位
Access-Control-Allow-Origin: 'http://www.siteB.com' # 指定域可以請求,萬用字元'*'(必須)
Access-Control-Allow-Methods: 'GET,PUT,POST,DELETE' # 指定允許的跨域請求方式(必須)
Access-Control-Allow-Headers: 'Content-Type' # 請求中必須包含的 http 頭欄位
Access-Control-Allow-Credentials: true # 配合請求中的 withCredentials 頭進行請求驗證
通過 express 實現也很簡單,在註冊路由之前新增:
var cors = require('cors'); // 通過 npm 安裝
app.use(cors());
當然你也可以自定義一箇中間件:
// 自定義中介軟體
var cors = function (req, res, next) {
// 自定義設定跨域需要的響應頭。
res.header('Access-Control-Allow-Origin', 'http://www.siteB.com');
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
next();
};
app.use(cors); // 運用跨域的中介軟體
WebSocket 協議跨域
ws 協議是 H5 中的 web 全雙工通訊解決方案,常規 http 屬於請求相應的過程,在客戶端沒有請求的情況下,服務端無法給客戶端主動推送資料,ws 協議解決了這個問題,但處於安全考慮,其同樣有同源策略的限制。
*這裡不討論通過長連線和服務端掛起請求等方法推送資料,本文只討論跨域。
下面舉個例子(依賴socket.io.js):
// 前端部分
socket.on('connect', function() {
// 監聽服務端訊息
socket.on('message', function(msg) {
console.log('data from server: ' + msg);
});
// 監聽服務端關閉
socket.on('disconnect', function() {
console.log('Server socket has closed.');
});
});
document.getElementById('input').onkeyup = function(e) {
if(!e.shiftKey && !e.ctrlKey && !e.altKey && e.keyCode === 13)
socket.send(this.value);
};
// 後端部分(node.js)
var http = require('http');
var socket = require('socket.io');
// 啟http服務
var server = http.createServer(function(req, res) {
res.writeHead(200, {
'Content-type': 'text/html'
});
res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
// 監聽socket連線
socket.listen(server).on('connection', function(client) {
// 監聽客戶端資訊
client.on('message', function(msg) {
client.send('hello:' + msg);
console.log('data from client: ' + msg);
});
// 監聽客戶端斷開
client.on('disconnect', function() {
console.log('Client socket has closed.');
});
});
HTML 標籤中的 crossorigin 屬性
HTML 中 <img>
, <video>
和 <script>
具有 crossorigin 屬性。新增屬性會使相應新增 CORS 相關 http 頭(需要伺服器支援)。同時,其還有以下可能的取值:
- user-credentials 該請求通過 cookie 交換 user-credentials,伺服器相應需新增 Access-Control-Allow-Origin
- anonymous 該請求不會通過 cookie 交換 user-credentials,伺服器相應需新增 Access-Control-Allow-Credentials
當只寫了 crossorigin 屬性沒有指定值時,其預設值為 "anonymous"。即以下兩行程式碼等價:
<scirpt src="a.com/vendor.js" corssorigin></script>
<scirpt src="a.com/vendor.js" corssorigin="anonymous"></script>
幾種不同的跨域方法比較
方法 | 使用條件 | 使用條件是否與後端互動 | 優點 | 缺點 | |
---|---|---|---|---|---|
JSONP | 服務端支援 jsonp 請求 | 是 | 相容所有瀏覽器 | 只支援 GET 請求,只能和服務端通訊 | |
CORS | 伺服器相應需要相關投資端支援 | 是 | 方便的錯誤處理,支援所有http請求型別 | 存在瀏覽器相容性問題(如今可以忽略了) | |
document.domain |
僅需要跨子域發起請求 | 是 | 使用便捷,沒有相容問題 | 對於完全不同的域名無法使用 | |
postMessage |
瀏覽器不同 window 間通訊、 iframe 和其宿主通訊 | 否 | 支援瀏覽器頁面間或頁面和 iframe 間同行 | 需要瀏覽器相容 H5 介面 | |
window.name |
iframe 和其宿主通訊 | 否 | 簡單易操作 | 資料暴露在全域性不安全 | |
location.hash |
iframe 和其宿主通訊 | 否 | 簡單易操作 | 資料在 url 中不安全並且有長度限制 | |
反向代理 | - | 是 | 任何情況都可用 | 使用比較麻煩,需要自己建立服務 |
擴充套件:基於 webpack 的反向代理配置示例
新增 webpack 配置如下:
const config = {
// ...
devServer: {
// ...
proxy: {
'/api': {
target: 'https://data.site-B.com/api2',
changeOrigin: true, // 允許跨域
secure: false // 允許訪問 https
},
'/': {
target: 'https://data.site-C.com',
changeOrigin: true,
secure: false
},
}
}
};
module.exports = config;
擴充套件:基於 Nginx 反向代理和CORS配置示例
- CORS 配置
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Methods: GET,PUT,POST,DELETE;
}
- 反向代理配置
server {
listen 7001;
server_name www.domain1.com;
location / {
proxy_pass http://www.B.com:7001; #反向代理
}
}