解鎖跨域的九種姿勢
作者: Pawn
時間: 2019.01.24
本文首發: Pawn部落格
github: github.com/LiChangyi
描述:分析跨域,解鎖跨域的九種姿勢。
寫在前面
針對本文的九種方法我均寫的有相應的demo演示(對應的前端檔案,後端檔案和配置檔案),強烈建議不熟悉的朋友都去嘗試一下。 本文github地址,fontService
是前端地址檔案,service
是後端檔案。
網路上存在很多不同的跨域文章,我在學習的時候基本上也是去看他們的文章,但是有些地方的確理解起來有點困難,所以本文就這樣產生了,希望能寫一點和現在網路上文章中都不一樣的東西。同時也把我自己的看法寫進去,和大家相互交流自己的看法。
跨域在以前一直折磨著每個前端開發者。但是現在,三大框架的普及,我們在開發的過程中,只修改小小的配置,便沒有這些顧慮。但實質上還是webpack-dev-server
已經幫我們處理了這一個問題,把當前請求,代理到目標伺服器上面,然後把獲取到的資料返回。所以,現在很多前端開發者,包括我在寫這篇文章之前,對跨域都不是很瞭解,只有一個個模模糊糊的概念,只會用jsonp
去獲取,或者直接用jq的jsonp方法去解決跨域,都不會去了解為什麼這樣就能解決跨域?甚至,很多人對跨域都已經放棄了,因為三大框架的學習,完善的腳手架功能,簡化了我們,專案部署也有後端的同學幫我們解決。但是個人認為跨域是每一個前端開發者需要必備的能力,不論是跨域獲取資料,還是後面進行ssr服務端渲染
的配置都需要了解一點跨域,瞭解一點請求的代理。
為什麼存在跨域
我們在解決的一個問題的同時我們應該先去了解這個問題是如何產生的。
之所以要使用跨域,最終的罪魁禍首都是瀏覽器的同源策略,瀏覽器的同源策略限制我們只能在相同的協議、IP地址、埠號相同,如果有任何一個不通,都不能相互的獲取資料。這裡注意一下,http
和https
之間也存在跨域,因為https一般採用的是443埠,http採用的是80埠或者其他。這裡也存在埠號的不同。想要詳細瞭解請看 => MDN對瀏覽器的同源策略的說明
雖然同源策略的確很可惡,但是如果沒有同源策略使用者將會陷入很境界。比如,你正在吃著火鍋哼著歌,逛著淘寶買東西,但是這時你的同學給你發了一個網址,然後你直接開啟來看,假如沒有同源策略,他在該網站中用一個iframe
的標籤然後把src指向淘寶網,這時沒有同源策略,他便可以直接通過js操作iframe的頁面,比如說獲取cookie
或者用js模擬點選這些操作(因為你已經登入過了,可以不用再次登入就點選了),危害是很大的。大家可以先去做一個小測驗,你在本地建立一個a.html
和b.html
2個檔案,然後在a.html
中用iframe中插入b.html
,你會發現當前2個頁面是一個域的,你可以在a中通過js控制b,和在b中直接用js操作沒有區別。但是如果你插入的不是同一個域下面的頁面,比如你插入的是淘寶,你會發現你不能通過js操作,你console.log(iframe.contentWindow)
你會發現只有少數的幾項。大家可以去看看大佬的文章:淺談CSRF攻擊方式,雖然沒有永遠牢靠的盾,但是有同源策略的存在,會讓攻擊的成本變得高一點。
雖然同源策略危害很大,但是我們還是在一定的場景下面需要進行跨域處理,比如說百度全家桶,你在百度地圖和百度搜尋2者之間肯定是放在2個域下面的(我沒有具體的去了解,但是我猜想肯定是這樣的)。在開發地圖的時候假如需要應用搜尋的時候就不得不用跨域了。比如:百度搜尋,輸入文字出現內容提示,如果我沒有判斷錯誤就是採用的jsonp來做得跨域。大家在學習了jsonp跨域的時候,可以去嘗試去獲取一下。
進入正題
1.JSONP
說起如何去解決跨域,我相信每個人腦袋中跳出來的第一個詞就是jsonp
。因為瀏覽器的同源策略不限制script
、link
、img
三個標籤,比如我們經常用這三個標籤來載入其他域的資源,我個人的看法這就已經算是跨域了。jsonp的跨域就是應用script
標籤可以進行獲取遠端的js指令碼檔案。
// 比如1.js 指令碼檔案
say('haha');
複製程式碼
我在html裡面引入1.js檔案,那麼他講會執行say
函式,我們需要傳入的資料就是haha
。
所以jsonp的方法就是動態的建立一個script
標籤,然後設定src為我們需要進行跨域的地址。當然這個方法需要後臺的設定。大家可以看我寫的程式碼,
前端檔案在fontEndService/www/demo1/index,html
btn.onclick = () => {
jsonp('http://127.0.0.1:8888/api/getdata?jsonp=displayData');
}
function jsonp(url) {
let script = document.createElement('script');
script.setAttribute('src', url);
document.getElementsByTagName('head')[0].appendChild(script);
}
function displayData(data) {
msg.innerText = JSON.stringify(data);
}
複製程式碼
然後後端程式碼是用koa寫的一個簡約的介面,檔案在service/app.js
// 簡約的後端程式碼,我們直接呼叫前端傳過來的需要執行的函式
router.get('/api/getdata', ctx => {
const params = get_params(ctx.request.url)
const data = {
title: '資料獲取',
list: [0, 1, 2]
}
ctx.body = `${params.jsonp || 'callback'}(${JSON.stringify(data)})`;
})
複製程式碼
前端通過script標籤給後臺傳送一個get請求,在jsonp=displayData(一個我們接受到資料然後執行的方法,該方法是前端的),當我後臺接受到請求後,就返回一個,執行displayData這個方法的指令碼。然後把我們需要傳遞的資料放在形參裡面。這樣就相當於我們在前端裡面執行displayData這個方法。用這個方法來實現跨域資源的共享。
此方法是比較常用的一個方法,簡單粗暴。但是此方法有一個致命的缺點就是隻支援GET
請求。所以說如果前端頁面僅僅是作為頁面的展示,就只獲取資料的話,只用此方法就沒有任何問題。
2.iframe+document.domain
這個方法,個人感覺不是特別的常用,因為這個跨域方法要求2個域之間的主域名相同,子域不同,比如a.xxx.com和b.xxx.com。如果不同的話是不行的。
此方法的思想就是設定頁面的document.domain
把他們設定成相同的域名,比如都設定成xxx.com
。這樣來繞過同源策略。很簡單的一個方法,具體的程式碼檔案請看github。
程式碼裡面的測試案列是,前端檔案在7777埠,後臺檔案在8888埠,前端如果需要請求後端的資料就存在跨域,所以我們在後端8888埠寫一個提供資料的中轉html,然後通過ajax或者其他的方法請求到資料,然後把資料往外暴露。此方法需要2個html都需要設定相同的主域。
3.iframe+location.hash
這種方法是一個很奇妙的方法,雖然我感覺很雞肋,但是它的實現方法很巧妙,我在學習的時候都感覺到不可思議,還能這麼玩?
首先我們需要了解hash是什麼?
比如有一個這樣的url:http://www.xxx.com#abc=123
,那麼我們通過執行location.hash
就可以得到這樣的一個字串#abc=123
,同時改變hash頁面是不會重新整理的。這一點,相信很多學習三大框架的朋友應該都見識過hash路由。所以說我們可以根據這一點來在#
後面加上我們需要傳遞的資料。
加入現在我們有A頁面在7777埠(前端顯示的檔案),B頁面在8888埠,後臺執行在8888埠。我們在A頁面中通過iframe
巢狀B頁面。
-
從A頁面要傳資料到B頁面
我們在A頁面中通過,修改
iframe
的src
的方法來修改hash的內容。然後在B頁面中新增setInterval
事件來監聽我們的hash是否改變,如果改變那麼就執行相應的操作。比如像後臺伺服器提交資料或者上傳圖片這些。 -
從B頁面傳遞資料到A頁面
經過上面的方法,那麼肯定有聰明的朋友就在想那麼,從B頁面向A頁面傳送資料就是修改A頁面的hash值了。對沒錯方法就是這樣,但是我在執行的時候會出現一些問題。我們在B頁面中直接:
parent.location.hash = "#xxxx" 複製程式碼
這樣是不行的,因為前面提到過的同源策略不能直接修改父級的hash值,所以這裡採用了一個我認為很巧妙的方法。部分程式碼:
try { parent.location.hash = `message=${JSON.stringify(data)}`; } catch (e) { // ie、chrome 的安全機制無法修改parent.location.hash, // 利用一箇中間html 的代理修改location.hash // 如A => B => C 其中,當前頁面是B,AC在相同的一個域下。B 不能直接修改A 的 hash值故修改 C,讓C 修改A // 檔案地址: fontEndService/www/demo3/proxy.html if (!ifrProxy) { ifrProxy = document.createElement('iframe'); ifrProxy.style.display = 'none'; document.body.appendChild(ifrProxy); } ifrProxy.src = `http://127.0.0.1:7777/demo3/proxy.html#message=${JSON.stringify(data)}`; } 複製程式碼
如果可以直接修改我們就直接修改,如果不能直接修改,那麼我們在B頁面中再新增一個
iframe
然後指向C頁面(我們暫時叫他代理頁面,此頁面和A頁面是在相同的一個域下面),我們可以用同樣的方法在url後面我們需要傳遞的資訊。在代理頁面中:parent.parent.location.hash = self.location.hash.substring(1); 複製程式碼
只需要寫這樣的一段js程式碼就完成了修改A頁面的hash值,同樣在A頁面中也新增一個
setInterval
事件來監聽hash值的改變。我們現在再來理一下思路。我們現在有三個頁面,A,B,C。
A頁面是我們前端顯示的頁面;
B頁面我們可以把他當做A頁面也後端資料互動的一箇中間頁面,完成接受A的資料和向後臺傳送請求。但是由於同源策略的限制我們不能在B頁面中直接修改A的hash值,所以我們需要藉助一個與A頁面在同一個域名下的C頁面。
C頁面我們把他當中一個代理頁面,我們因為他和A頁面在一個域下,所以可以修改A的hash值。所以B頁面修改C頁面的hash值,然後C頁面修改A頁面的hash值。(C就是一個打工的)
此方法雖然我個人感覺實現的思路很巧妙但是,使用價值似乎不高,因為他實現的核心思路就是通過修改URL的hash值,然後用定時器來監聽值的改變來修改。所以說最大的問題就是,我們傳遞的資料會直接在URL裡面顯示出來,不是很安全,同時URL的長度是一定的所以傳輸的資料也是有限的。
4.iframe+window.name
相比於前面2種iframe
的方法,這種方法的使用人數要多一點。因為他有效的解決了前面2種方法很大的缺點。這種方法的原理就是window.name屬性在於載入不同的頁面(包括域名不同的情況下),如果name值沒有修改,那麼它將不會變化。並且這個值可以非常的長(2MB)
方法原理:A頁面通過iframe
載入B頁面。B頁面獲取完資料後,把資料賦值給window.name。然後在A頁面中修改iframe
使他指向本域的一個頁面。這樣在A頁面中就可以直接通過iframe.contentWindow.name
獲取到B頁面中獲取到的資料。
A頁面中部分程式碼:
let mark = false;
let ifr = document.createElement('iframe');
ifr.src = "http://127.0.0.1:8888/demo4";
ifr.style.display = 'none';
document.body.appendChild(ifr);
ifr.onload = () => {
// iframe 中資料載入完成,觸發onload事件
if (mark) {
msg.innerText = ifr.contentWindow.name;// 這就是資料
document.body.removeChild(ifr);
mark = false;
} else {
mark = true;
// 修改src指向本域的一個頁面(這個頁面什麼都沒有)
ifr.contentWindow.location = "http://127.0.0.1:7777/demo4/proxy.html";
}
}
複製程式碼
5.postMessage
postMessage是HTML5引入的API。他可以解決多個視窗之間的通訊(包括域名的不同)。我個人認為他算是一種訊息的推送,可以給每個視窗推送。然後在目標視窗新增message
的監聽事件。從而獲取推送過來的資料。
A頁面中部分程式碼:
<iframe id="iframe" src="http://127.0.0.1:8888/demo5" frameborder="0"></iframe>
iframe.contentWindow.postMessage('getData', 'http://127.0.0.1:8888');
// 監聽其他頁面給我傳送的資料
window.addEventListener('message', e => {
if (e.origin !== 'http://127.0.0.1:8888') return;
msg.innerText = e.data;
})
複製程式碼
這裡我們給目標視窗127.0.0.1:8888
推送了getData的資料。然後在B頁面中新增事件的監聽。
B頁面中部分程式碼:
window.addEventListener('message', e => {
// 判斷來源是不是我們信任的站點,防止被攻擊
if (e.origin !== 'http://127.0.0.1:7777') return;
const data = e.data;
if (data === 'getData') {
// 根據接受到的資料,來進行下一步的操作
myAjax('/api/getdata', notice);
}
})
function notice(data) {
// 向後臺請求到資料以後,在向父級推送訊息
top.postMessage(JSON.stringify(data), 'http://127.0.0.1:7777')
}
複製程式碼
我個人認為這種方式就是一個事件的釋出與監聽,至少說可以無視同源策略。
6.cors
其實對於跨域資源的請求,瀏覽器已經把我們的請求發放給了伺服器,瀏覽器也接受到了伺服器的響應,只是瀏覽器一看我們2個的域不一樣就把訊息給攔截了,不給我們顯示。所以我們如果我們在伺服器就告訴瀏覽器我這個資料是每個源都可以獲取就可以了。這就是CORS跨域資源共享。
在後臺程式碼中我以KOA列子
const Koa = require('koa');
const router = require('koa-router')();
// 引入中介軟體
const cors = require('koa2-cors');
const app = new Koa();
// 根據後臺伺服器的型別,開啟跨域設定
// cors安全風險很高,所以,實際線上的配置肯定要比這個更加複雜,需要根據自己的需求來做
app.use(cors());
router.get('/api/getdata', ctx => {
ctx.body = {
code: 200,
msg: '我是配置有cors的伺服器傳輸的資料'
}
})
app.use(router.routes(), router.allowedMethods());
console.log('配置有cors的伺服器地址在:http://127.0.0.1:8889');
app.listen(8889);
複製程式碼
這樣的話,任何源都可以通過AJAX發起請求來獲取我們提供的資料。針對不同語言的伺服器後端有不一樣的處理方法,但是實質是一樣的。
配置了CORS的瀏覽器請求響應資訊
跨域請求響應資訊7.NGINX
採用nginx做代理應該是目前跨域解決方案最好的一種。現在強調前後端分離,前端根據後端提供的介面進行資料的互動然後渲染頁面。在前端三大框架的同時,開發過程中不需要我們針對跨域配置很多。在網頁上線以後。我們經常採用nginx來載入靜態的資源,我們把我們前端打包好的檔案放到nginx的目錄下面,讓nginx來處理客服端的靜態資源的請求。然後後端部署到另外一個埠號上面,當我們需要進行資料的互動的時候,通過nginx代理把後端資料代理到前端頁面。這樣的步驟是相較於傳統的跨域是最簡單也是最有效的一種方法,因為nginx又沒有同源策略。不用考慮什麼相容性也不用考慮資料大小。我們在伺服器(或者測試程式碼的時候在本地)安裝nginx服務,然後找到我們nginx的配置檔案,新增以下配置檔案:
server {
# 把頁面部署的埠
listen 8080;
# 靜態頁面存放的目錄
root /var/www/html;
index index.html index.htm index.php;
# 只代理 /api 開頭的介面,其他介面不代理
location /api/ {
# 需要代理的地址, 輸入我們的後臺api地址
proxy_pass http://127.0.0.1:8888;
}
}
複製程式碼
這樣,我們可以代理不同url開頭的請求到不同的後端進行處理,對以後伺服器做負載均衡和反向代理也很簡單。
8.nodejs
其實這種辦法和上一種用nginx的方法是差不多的。都是你把請求發給一箇中間人,由於中間人沒有同源策略,他可以直接代理或者通過爬蟲或者其他的手段得到想到的資料,然後返回(是不是和VPN的原理有點類似)。
當然現在常見的就是用nodejs作為資料的中介軟體,同樣,不同的語言有不同的方法,但是本質是一樣的。我上次自己同構自己的部落格頁面,用react
伺服器端渲染,因為瀏覽器的同源策略,請求不到資料,然後就用了nodejs作為中介軟體來代理請求資料。
部分程式碼:
const Koa = require('koa');
// 代理
const Proxy = require('koa-proxy');
// 對以前的非同步函式進行轉換
const Convert = require('koa-convert');
const app = new Koa();
const server = require('koa-static');
app.use(server(__dirname+"/www/",{ extensions: ['html']}));
app.use(Convert(Proxy({
// 需要代理的介面地址
host: 'http://127.0.0.1:8888',
// 只代理/api/開頭的url
match: /^\/api\//
})));
console.log('服務執行在:http://127.0.0.1:7777');
app.listen(7777);
複製程式碼
是不是和nginx很類似呀!!
9.webSocket
webSocket大家應該都有所耳聞,主要是為了客服端和服務端進行全雙工的通訊。但是這種通訊是可以進行跨埠的。所以說我們可以用這個漏洞
來進行跨域資料的互動。
我個人認為,這種方法實質上和postMessage差不多,但是不是特別的常用吧!(僅僅個人看法)
所以說我們可以很輕鬆的構建基於webSocket
構建一個客服端和服務端。程式碼在github建議大家都多多去執行一下,瞭解清楚。這裡就不貼了。
最後
哇!長長短短的寫了5000多字終於寫到最後了!!寫總結真的比寫程式碼還困難。
個人覺得,第1種方法和第6種方法是以前常用的一種方法,畢竟以前基本上都是刀耕火種的前端時代。然後2,3,4種方法基本上現在很少有人會用,包括我沒去詳細瞭解之前也不會,但是裡面有很多思想卻值得我們去思考,比如第3種方法,反正我個人覺得很巧妙。第5,9種個人認為,這2種方法雖然可以解決跨域,但是把他們用在跨域有點大材小用
瞭解就好。第7,8種方法,覺得應該是現在每個前端er都應該掌握的方法,應該以後解決跨域的主要方法。
所有的程式碼都在github上面,地址在文章開頭,我強烈建議都clone下來跑一邊程式碼,最好是結合自己的理解把9種方法都去實現一下。
由於我也是才瞭解跨域不久,本文有很多地方僅僅是我個人看法,歡迎大佬補充、勘誤!