跨域那點事~

作者:寫bug
來源:https://segmentfault.com/a/1190000015597029
引言
跨域這兩個字就像一塊狗皮膏藥一樣黏在每一個前端開發(fā)者身上,無論你在工作上或者面試中無可避免會遇到這個問題。為了應(yīng)付面試,我每次都隨便背幾個方案,也不知道為什么要這樣干,反正面完就可以扔了,我想工作上也不會用到那么多亂七八糟的方案。
到了真正工作,開發(fā)環(huán)境有webpack-dev-server搞定,上線了服務(wù)端的大佬們也會配好,配了什么我不管,反正不會跨域就是了。日子也就這么混過去了,終于有一天,我覺得不能再繼續(xù)這樣混下去了,我一定要徹底搞懂這個東西!于是就有了這篇文章。
要掌握跨域,首先要知道為什么會有跨域這個問題出現(xiàn)
確實,我們這種搬磚工人就是為了混口飯吃嘛,好好的調(diào)個接口告訴我跨域了,這種阻礙我們輕松搬磚的事情真惡心!為什么會跨域?是誰在搞事情?為了找到這個問題的始作俑者,請點擊瀏覽器的同源策略[1]。
這么官方的東西真難懂,沒關(guān)系,至少你知道了,因為瀏覽器的同源策略導(dǎo)致了跨域,就是瀏覽器在搞事情。
所以,瀏覽器為什么要搞事情?就是不想給好日子我們過?對于這樣的質(zhì)問,瀏覽器甩鍋道:“同源策略限制了從同一個源加載的文檔或腳本如何與來自另一個源的資源進行交互。這是一個用于隔離潛在惡意文件的重要安全機制。”
這么官方的話術(shù)真難懂,沒關(guān)系,至少你知道了,似乎這是個安全機制。
所以,究竟為什么需要這樣的安全機制?這樣的安全機制解決了什么問題?別急,讓我們繼續(xù)研究下去。
沒有同源策略限制的兩大危險場景
據(jù)我了解,瀏覽器是從兩個方面去做這個同源策略的,一是針對接口的請求,二是針對Dom的查詢。試想一下沒有這樣的限制上述兩種動作有什么危險。
沒有同源策略限制的接口請求
有一個小小的東西叫cookie大家應(yīng)該知道,一般用來處理登錄等場景,目的是讓服務(wù)端知道誰發(fā)出的這次請求。
如果你請求了接口進行登錄,服務(wù)端驗證通過后會在響應(yīng)頭加入Set-Cookie字段,然后下次再發(fā)請求的時候,瀏覽器會自動將cookie附加在HTTP請求的頭字段Cookie中,服務(wù)端就能知道這個用戶已經(jīng)登錄過了。
知道這個之后,我們來看場景:
1.你準備去清空你的購物車,于是打開了買買買網(wǎng)站www.maimaimai.com,然后登錄成功,一看,購物車東西這么少,不行,還得買多點。
2.你在看有什么東西買的過程中,你的好基友發(fā)給你一個鏈接www.nidongde.com,一臉yin笑地跟你說:“你懂的”,你毫不猶豫打開了。
3.你饒有興致地瀏覽著www.nidongde.com,誰知這個網(wǎng)站暗地里做了些不可描述的事情!由于沒有同源策略的限制,它向www.maimaimai.com發(fā)起了請求!聰明的你一定想到上面的話“服務(wù)端驗證通過后會在響應(yīng)頭加入Set-Cookie字段,然后下次再發(fā)請求的時候,瀏覽器會自動將cookie附加在HTTP請求的頭字段Cookie中”,這樣一來,這個不法網(wǎng)站就相當于登錄了你的賬號,可以為所欲為了!如果這不是一個買買買賬號,而是你的銀行賬號,那……
這就是傳說中的CSRF攻擊淺談CSRF攻擊方式[2]。
看了這波CSRF攻擊我在想,即使有了同源策略限制,但cookie是明文的,還不是一樣能拿下來。
于是我看了一些cookie相關(guān)的文章聊一聊 cookie[3]、Cookie/Session的機制與安全[4],知道了服務(wù)端可以設(shè)置httpOnly,使得前端無法操作cookie,如果沒有這樣的設(shè)置,像XSS攻擊就可以去獲取到cookieWeb安全測試之XSS[5];設(shè)置secure,則保證在https的加密通信中傳輸以防截獲。
沒有同源策略限制的Dom查詢
1.有一天你剛睡醒,收到一封郵件,說是你的銀行賬號有風險,趕緊點進www.yinghang.com改密碼。你嚇尿了,趕緊點進去,還是熟悉的銀行登錄界面,你果斷輸入你的賬號密碼,登錄進去看看錢有沒有少了。
2.睡眼朦朧的你沒看清楚,平時訪問的銀行網(wǎng)站是www.yinhang.com,而現(xiàn)在訪問的是www.yinghang.com,這個釣魚網(wǎng)站做了什么呢?
//?HTML
由此我們知道,同源策略確實能規(guī)避一些危險,不是說有了同源策略就安全,只是說同源策略是一種瀏覽器最基本的安全機制,畢竟能提高一點攻擊的成本。其實沒有刺不穿的盾,只是攻擊的成本和攻擊成功后獲得的利益成不成正比。
跨域正確的打開方式
經(jīng)過對同源策略的了解,我們應(yīng)該要消除對瀏覽器的誤解,同源策略是瀏覽器做的一件好事,是用來防御來自邪門歪道的攻擊,但總不能為了不讓壞人進門而把全部人都拒之門外吧。沒錯,我們這種正人君子只要打開方式正確,就應(yīng)該可以跨域。
下面將一個個演示正確打開方式,但在此之前,有些準備工作要做。為了本地演示跨域,我們需要:
1.隨便跑起一份前端代碼(以下前端是隨便跑起來的vue),地址是http://localhost:9099。
2.隨便跑起一份后端代碼(以下后端是隨便跑起來的node koa2),地址是http://localhost:9971。
同源策略限制下接口請求的正確打開方式
1.JSONP在HTML標簽里,一些標簽比如script、img這樣的獲取資源的標簽是沒有跨域限制的,利用這一點,我們可以這樣干:
后端寫個小接口
//?處理成功失敗返回格式的工具
const?{successBody}?=?require('../utli')
class?CrossDomain?{
??static?async?jsonp?(ctx)?{
????//?前端傳過來的參數(shù)
????const?query?=?ctx.request.query
????//?設(shè)置一個cookies
????ctx.cookies.set('tokenId',?'1')
????// query.cb是前后端約定的方法名字,其實就是后端返回一個直接執(zhí)行的方法給前端,由于前端是用script標簽發(fā)起的請求,所以返回了這個方法后相當于立馬執(zhí)行,并且把要返回的數(shù)據(jù)放在方法的參數(shù)里。
????ctx.body?=?`${query.cb}(${JSON.stringify(successBody({msg:?query.msg},?'success'))})`
??}
}
module.exports?=?CrossDomain
簡單版前端
<html>
??<head>
????<meta?charset="utf-8">
??head>
??<body>
????<script?type='text/javascript'>
??????//?后端返回直接執(zhí)行的方法,相當于執(zhí)行這個方法,由于后端把返回的數(shù)據(jù)放在方法的參數(shù)里,所以這里能拿到res。
??????window.jsonpCb?=?function?(res)?{
????????console.log(res)
??????}
????script>
????<script?src='http://localhost:9871/api/jsonp?msg=helloJsonp&cb=jsonpCb'?type='text/javascript'>script>
??body>
html>
簡單封裝一下前端這個套路
/**
?*?JSONP請求工具
?*?@param?url?請求的地址
?*?@param?data?請求的參數(shù)
?*?@returns?{Promise}
?*/
const?request?=?({url,?data})?=>?{
??return?new?Promise((resolve,?reject)?=>?{
????//?處理傳參成xx=yy&aa=bb的形式
????const?handleData?=?(data)?=>?{
??????const?keys?=?Object.keys(data)
??????const?keysLen?=?keys.length
??????return?keys.reduce((pre,?cur,?index)?=>?{
????????const?value?=?data[cur]
????????const?flag?=?index?!==?keysLen?-?1???'&'?:?''
????????return?`${pre}${cur}=${value}${flag}`
??????},?'')
????}
????//?動態(tài)創(chuàng)建script標簽
????const?script?=?document.createElement('script')
????//?接口返回的數(shù)據(jù)獲取
????window.jsonpCb?=?(res)?=>?{
??????document.body.removeChild(script)
??????delete?window.jsonpCb
??????resolve(res)
????}
????script.src?=?`${url}?${handleData(data)}&cb=jsonpCb`
????document.body.appendChild(script)
??})
}
//?使用方式
request({
??url:?'http://localhost:9871/api/jsonp',
??data:?{
????//?傳參
????msg:?'helloJsonp'
??}
}).then(res?=>?{
??console.log(res)
})
2.空iframe加form
細心的朋友可能發(fā)現(xiàn),JSONP只能發(fā)GET請求,因為本質(zhì)上script加載資源就是GET,那么如果要發(fā)POST請求怎么辦呢?
后端寫個小接口
//?處理成功失敗返回格式的工具
const?{successBody}?=?require('../utli')
class?CrossDomain?{
??static?async?iframePost?(ctx)?{
????let?postData?=?ctx.request.body
????console.log(postData)
????ctx.body?=?successBody({postData:?postData},?'success')
??}
}
module.exports?=?CrossDomain
前端
const?requestPost?=?({url,?data})?=>?{
??//?首先創(chuàng)建一個用來發(fā)送數(shù)據(jù)的iframe.
??const?iframe?=?document.createElement('iframe')
??iframe.name?=?'iframePost'
??iframe.style.display?=?'none'
??document.body.appendChild(iframe)
??const?form?=?document.createElement('form')
??const?node?=?document.createElement('input')
??//?注冊iframe的load事件處理程序,如果你需要在響應(yīng)返回時執(zhí)行一些操作的話.
??iframe.addEventListener('load',?function?()?{
????console.log('post?success')
??})
??form.action?=?url
??//?在指定的iframe中執(zhí)行form
??form.target?=?iframe.name
??form.method?=?'post'
??for?(let?name?in?data)?{
????node.name?=?name
????node.value?=?data[name].toString()
????form.appendChild(node.cloneNode())
??}
??//?表單元素需要添加到主文檔中.
??form.style.display?=?'none'
??document.body.appendChild(form)
??form.submit()
??//?表單提交后,就可以刪除這個表單,不影響下次的數(shù)據(jù)發(fā)送.
??document.body.removeChild(form)
}
//?使用方式
requestPost({
??url:?'http://localhost:9871/api/iframePost',
??data:?{
????msg:?'helloIframePost'
??}
})
3.CORS
CORS是一個W3C標準,全稱是"跨域資源共享"(Cross-origin resource sharing)跨域資源共享 CORS 詳解[6]。看名字就知道這是處理跨域問題的標準做法。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
1.簡單請求
后端
//?處理成功失敗返回格式的工具
const?{successBody}?=?require('../utli')
class?CrossDomain?{
??static?async?cors?(ctx)?{
????const?query?=?ctx.request.query
????//?*時cookie不會在http請求中帶上
????ctx.set('Access-Control-Allow-Origin',?'*')
????ctx.cookies.set('tokenId',?'2')
????ctx.body?=?successBody({msg:?query.msg},?'success')
??}
}
module.exports?=?CrossDomain
前端什么也不用干,就是正常發(fā)請求就可以,如果需要帶cookie的話,前后端都要設(shè)置一下,下面那個非簡單請求例子會看到。
fetch(`http://localhost:9871/api/cors?msg=helloCors`).then(res?=>?{
??console.log(res)
})
2.非簡單請求
非簡單請求會發(fā)出一次預(yù)檢測請求,返回碼是204,預(yù)檢測通過才會真正發(fā)出請求,這才返回200。這里通過前端發(fā)請求的時候增加一個額外的headers來觸發(fā)非簡單請求。

后端
//?處理成功失敗返回格式的工具
const?{successBody}?=?require('../utli')
class?CrossDomain?{
??static?async?cors?(ctx)?{
????const?query?=?ctx.request.query
????//?如果需要http請求中帶上cookie,需要前后端都設(shè)置credentials,且后端設(shè)置指定的origin
????ctx.set('Access-Control-Allow-Origin',?'http://localhost:9099')
????ctx.set('Access-Control-Allow-Credentials',?true)
????//?非簡單請求的CORS請求,會在正式通信之前,增加一次HTTP查詢請求,稱為"預(yù)檢"請求(preflight)
????//?這種情況下除了設(shè)置origin,還需要設(shè)置Access-Control-Request-Method以及Access-Control-Request-Headers
????ctx.set('Access-Control-Request-Method',?'PUT,POST,GET,DELETE,OPTIONS')
????ctx.set('Access-Control-Allow-Headers',?'Origin,?X-Requested-With,?Content-Type,?Accept,?t')
????ctx.cookies.set('tokenId',?'2')
????ctx.body?=?successBody({msg:?query.msg},?'success')
??}
}
module.exports?=?CrossDomain
一個接口就要寫這么多代碼,如果想所有接口都統(tǒng)一處理,有什么更優(yōu)雅的方式呢?見下面的koa2-cors。
const?path?=?require('path')
const?Koa?=?require('koa')
const?koaStatic?=?require('koa-static')
const?bodyParser?=?require('koa-bodyparser')
const?router?=?require('./router')
const?cors?=?require('koa2-cors')
const?app?=?new?Koa()
const?port?=?9871
app.use(bodyParser())
//?處理靜態(tài)資源?這里是前端build好之后的目錄
app.use(koaStatic(
??path.resolve(__dirname,?'../dist')
))
//?處理cors
app.use(cors({
??origin:?function?(ctx)?{
????return?'http://localhost:9099'
??},
??credentials:?true,
??allowMethods:?['GET',?'POST',?'DELETE'],
??allowHeaders:?['t',?'Content-Type']
}))
//?路由
app.use(router.routes()).use(router.allowedMethods())
//?監(jiān)聽端口
app.listen(9871)
console.log(`[demo]?start-quick?is?starting?at?port?${port}`)
前端
fetch(`http://localhost:9871/api/cors?msg=helloCors`,?{
??//?需要帶上cookie
??credentials:?'include',
??//?這里添加額外的headers來觸發(fā)非簡單請求
??headers:?{
????'t':?'extra?headers'
??}
}).then(res?=>?{
??console.log(res)
})
4.代理
想一下,如果我們請求的時候還是用前端的域名,然后有個東西幫我們把這個請求轉(zhuǎn)發(fā)到真正的后端域名上,不就避免跨域了嗎?這時候,Nginx出場了。
Nginx配置
server{
????#?監(jiān)聽9099端口
????listen?9099;
????#?域名是localhost
????server_name?localhost;
????#凡是localhost:9099/api這個樣子的,都轉(zhuǎn)發(fā)到真正的服務(wù)端地址http://localhost:9871?
????location?^~?/api?{
????????proxy_pass?http://localhost:9871;
????}????
}
前端就不用干什么事情了,除了寫接口,也沒后端什么事情了
//?請求的時候直接用回前端這邊的域名http://localhost:9099,這就不會跨域,然后Nginx監(jiān)聽到凡是localhost:9099/api這個樣子的,都轉(zhuǎn)發(fā)到真正的服務(wù)端地址http://localhost:9871?
fetch('http://localhost:9099/api/iframePost',?{
??method:?'POST',
??headers:?{
????'Accept':?'application/json',
????'Content-Type':?'application/json'
??},
??body:?JSON.stringify({
????msg:?'helloIframePost'
??})
})
Nginx轉(zhuǎn)發(fā)的方式似乎很方便!但這種使用也是看場景的,如果后端接口是一個公共的API,比如一些公共服務(wù)獲取天氣什么的,前端調(diào)用的時候總不能讓運維去配置一下Nginx,如果兼容性沒問題(IE 10或者以上),CROS才是更通用的做法吧。
同源策略限制下Dom查詢的正確打開方式
1.postMessage
window.postMessage() 是HTML5的一個接口,專注實現(xiàn)不同窗口不同頁面的跨域通訊。
為了演示方便,我們將hosts改一下:127.0.0.1 crossDomain.com,現(xiàn)在訪問域名crossDomain.com就等于訪問127.0.0.1。
這里是http://localhost:9099/#/crossDomain,發(fā)消息方
<template>
??<div>
????<button?@click="postMessage">給http://crossDomain.com:9099發(fā)消息button>
????<iframe?name="crossDomainIframe"?src="http://crossdomain.com:9099">iframe>
??div>
template>
<script>
export?default?{
??mounted?()?{
????window.addEventListener('message',?(e)?=>?{
??????//?這里一定要對來源做校驗
??????if?(e.origin?===?'http://crossdomain.com:9099')?{
????????//?來自http://crossdomain.com:9099的結(jié)果回復(fù)
????????console.log(e.data)
??????}
????})
??},
??methods:?{
????//?向http://crossdomain.com:9099發(fā)消息
????postMessage?()?{
??????const?iframe?=?window.frames['crossDomainIframe']
??????iframe.postMessage('我是[http://localhost:9099],?麻煩你查一下你那邊有沒有id為app的Dom',?'http://crossdomain.com:9099')
????}
??}
}
script>
這里是http://crossdomain.com:9099,接收消息方
<template>
??<div>
????我是http://crossdomain.com:9099
??div>
template>
<script>
export?default?{
??mounted?()?{
????window.addEventListener('message',?(e)?=>?{
??????//?這里一定要對來源做校驗
??????if?(e.origin?===?'http://localhost:9099')?{
????????//?http://localhost:9099發(fā)來的信息
????????console.log(e.data)
????????//?e.source可以是回信的對象,其實就是http://localhost:9099窗口對象(window)的引用
????????//?e.origin可以作為targetOrigin
????????e.source.postMessage(`我是[http://crossdomain.com:9099],我知道了兄弟,這就是你想知道的結(jié)果:${document.getElementById('app')???'有id為app的Dom'?:?'沒有id為app的Dom'}`,?e.origin);
??????}
????})
??}
}
script>
結(jié)果可以看到:

2.document.domain
這種方式只適合主域名相同,但子域名不同的iframe跨域。
比如主域名是http://crossdomain.com:9099,子域名是http://child.crossdomain.com:9099,這種情況下給兩個頁面指定一下document.domain即document.domain = crossdomain.com就可以訪問各自的window對象了。
3.canvas操作圖片的跨域問題
這個應(yīng)該是一個比較冷門的跨域問題,張大神已經(jīng)寫過了我就不再班門弄斧了解決canvas圖片getImageData,toDataURL跨域問題[7]
最后
希望看完這篇文章之后,再有人問跨域的問題,你可以嘴角微微上揚,冷笑一聲:“不要再問我跨域的問題了。” ?揚長而去。
如果學到了可以點在看讓更多的小伙伴看到哦 。
參考資料
[1]瀏覽器的同源策略:?https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy
[2]淺談CSRF攻擊方式:?http://www.cnblogs.com/hyddd/archive/2009/04/09/1432744.html
[3]聊一聊 cookie:?https://segmentfault.com/a/1190000004556040#articleHeader6
[4]Cookie/Session的機制與安全:?https://harttle.land/2015/08/10/cookie-session.html
[5]Web安全測試之XSS:?https://www.cnblogs.com/TankXiao/archive/2012/03/21/2337194.html
[6]跨域資源共享 CORS 詳解:?http://www.ruanyifeng.com/blog/2016/04/cors.html
[7]解決canvas圖片getImageData,toDataURL跨域問題:?https://www.zhangxinxu.com/wordpress/2018/02/crossorigin-canvas-getimagedata-cors/
- END-?
掃一掃二維碼回復(fù),"2020",獲得2020年最新前端、后端、大數(shù)據(jù)、人工智能、PHP等視頻教程的百度云盤鏈接,獲得之后,記得保存在自己的云盤里哦。
編程·前端·社區(qū)