一文了解 CORS 跨域
作為一個(gè) Web 開發(fā),一定不會(huì)對(duì)下面的跨域報(bào)錯(cuò)陌生。
當(dāng)一個(gè)資源從與該資源本身所在的服務(wù)器不同的域或端口請(qǐng)求一個(gè)資源時(shí),資源會(huì)發(fā)起一個(gè)跨域 HTTP 請(qǐng)求。例如站點(diǎn) http://www.aliyun.com 的某 HTML 頁面請(qǐng)求 http://www.alibaba.com/image.jpg。
出于安全原因,瀏覽器限制從頁面腳本內(nèi)發(fā)起的跨域請(qǐng)求,有些瀏覽器不會(huì)限制跨域請(qǐng)求的發(fā)起,但是會(huì)將結(jié)果攔截。 這意味著使用這些 API 的 Web 應(yīng)用只能加載同一個(gè)域下的資源,除非使用 CORS 機(jī)制(Cross-Origin Resource Sharing 跨源資源共享)獲取目標(biāo)服務(wù)器的授權(quán)來解決這個(gè)問題。
這也是本文將要探討的主要問題,需要額外強(qiáng)調(diào)的是,跨域問題產(chǎn)生的主體是“瀏覽器”,這也是為什么,當(dāng)我們使用 curl、postman、各種語言的 HTTP 客戶端等工具時(shí),從來沒有被跨域問題困擾過。
什么是跨域
http://www.aliyun.com 站點(diǎn)訪問 http://www.alibaba.com/image.jpg 很容易被判斷為一個(gè)跨域請(qǐng)求,因?yàn)橛蛎灰粯樱床呗栽敿?xì)描述如下:
- 協(xié)議相同
- 域名相同
- 端口相同
以下是跨域與同源的一些示例
| 站點(diǎn) | 資源訪問 | 跨域 or 同源 |
|---|---|---|
| http://www.aliyun.com | http://www.aliyun.com/hello | 同源 |
| http://www.aliyun.com | http://aliyun.com/hello | 跨域(域名不同,子域名和父域名也屬于不同域名) |
| http://www.aliyun.com | https://www.aliyun.com/hello | 跨域(協(xié)議不同) |
| http://www.aliyun.com | https://www.aliyun.com:81/hello | 跨域(端口不同) |
同源策略存在的原因是為了保護(hù)用戶的安全和隱私,防止惡意網(wǎng)站對(duì)其他網(wǎng)站進(jìn)行攻擊或?yàn)E用。如果沒有同源機(jī)制,以下一些常見的跨域攻擊方式將會(huì)讓網(wǎng)站維護(hù)者不堪其擾:
- CSRF(Cross-Site Request Forgery):攻擊者在惡意網(wǎng)站中放置一個(gè)含有惡意請(qǐng)求的頁面,并誘使用戶訪問該頁面。當(dāng)用戶在其他網(wǎng)站登錄時(shí),惡意請(qǐng)求會(huì)自動(dòng)發(fā)送給目標(biāo)網(wǎng)站,以偽裝成用戶的操作。這樣,攻擊者可以利用用戶已經(jīng)登錄的憑證進(jìn)行惡意操作,如修改密碼、發(fā)起交易等。
- XSS(Cross-Site Scripting):攻擊者在合法網(wǎng)站的輸入框或評(píng)論中注入惡意腳本代碼。當(dāng)用戶訪問包含惡意腳本的頁面時(shí),腳本會(huì)在用戶的瀏覽器中執(zhí)行。攻擊者可以利用這種方式竊取用戶的登錄憑證、敏感信息或執(zhí)行其他惡意操作。
- Clickjacking:攻擊者通過在一個(gè)網(wǎng)頁上覆蓋一個(gè)透明的、惡意的圖層,來欺騙用戶點(diǎn)擊看似無害的內(nèi)容,實(shí)際上是觸發(fā)了惡意操作,如轉(zhuǎn)賬或進(jìn)行其他敏感操作。
解決跨域問題,常見的方案有:
- CORS(跨域資源共享):在服務(wù)器端設(shè)置響應(yīng)頭部,允許指定的域名訪問資源。
- JSONP(JSON with Padding):通過在頁面中動(dòng)態(tài)添加
<script>元素,利用 script 標(biāo)簽的跨域特性來獲取數(shù)據(jù)。 - 代理服務(wù)器:在服務(wù)器端設(shè)置一個(gè)代理服務(wù)器,將請(qǐng)求代理轉(zhuǎn)發(fā)到目標(biāo)服務(wù)器,繞過瀏覽器的同源策略。
本文將會(huì)主要介紹 CORS 跨域資源共享方案。
CORS 跨域資源共享介紹
CORS 被定義在 w3c 規(guī)范中:https://fetch.spec.whatwg.org/#http-cors-protocol,這里包含了最詳細(xì)也最官方的描述。它并不是一個(gè)框架或者工具,而是一種機(jī)制、契約,當(dāng)瀏覽器和后端服務(wù)同時(shí)遵守 CORS 規(guī)范時(shí),跨域訪問便成了可能。根據(jù)使用經(jīng)驗(yàn),我們將 CORS 的機(jī)制分成了兩種模式:簡(jiǎn)單請(qǐng)求模式和預(yù)檢請(qǐng)求模式。
同時(shí)符合以下條件,就屬于簡(jiǎn)單請(qǐng)求模式:
- 使用以下 HTTP 方法之一:GET、POST、HEAD
- 除了簡(jiǎn)單請(qǐng)求頭之外(例如 content-type),不能包含自定義請(qǐng)求頭(例如通過 XMLHttpRequest.setRequestHeader 設(shè)置的請(qǐng)求頭)
- Content-Type 為 application/x-www-form-urlencoded、multipart/form-data 或 text/plain 之一 (application/x-www-form-urlencoded 由于是表單格式,屬于簡(jiǎn)單請(qǐng)求,而 application/json 的請(qǐng)求體需要拆包,不屬于簡(jiǎn)單請(qǐng)求)
對(duì)于不符合簡(jiǎn)單請(qǐng)求模式的請(qǐng)求,瀏覽器將會(huì)啟用預(yù)檢請(qǐng)求模式。
簡(jiǎn)單請(qǐng)求模式
瀏覽器在出現(xiàn)跨域請(qǐng)求時(shí),會(huì)自動(dòng)給請(qǐng)求攜帶 Origin 請(qǐng)求頭,以下圖為例,是 http://edasnext.aliyun.com 發(fā)往 http://edas.aliyun.com 的一個(gè)跨域請(qǐng)求
服務(wù)端如果要正常支持跨域請(qǐng)求,在判斷當(dāng)前請(qǐng)求為跨域請(qǐng)求時(shí),需要在響應(yīng)中攜帶 Access-Control-Allow-Origin、Access-Control-Allow-Methods 等相關(guān)的響應(yīng)頭。如果 edasnext.aliyun.com 該來源不在服務(wù)端的跨域配置列表中,則返回 403 拒絕該請(qǐng)求。瀏覽器會(huì)檢查 Access 相關(guān)的響應(yīng)頭,如果沒有攜帶,則會(huì)出現(xiàn)文章最開始的跨域報(bào)錯(cuò)。
Access to XMLHttpRequest at 'http://edas.aliyun.com/testCors' from origin 'http://edasnext.aliyun.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
簡(jiǎn)單請(qǐng)求模式-快速開始
為了更加直觀理解 CORS 的簡(jiǎn)單請(qǐng)求模式,本節(jié)快速開始給出了一個(gè)由 springboot 構(gòu)建的 demo,它和大多數(shù)業(yè)務(wù)應(yīng)用的項(xiàng)目結(jié)構(gòu)類似。
編寫 RestController
@RestController
public class IndexController {
@RequestMapping("/testCors")
public String testCors(HttpServletResponse response) {
System.out.println("hello cors");
return "hello cors";
}
}
并配置啟動(dòng)端口為 80。
編寫跨域請(qǐng)求前端
<div id="test"></div>
<input type="button" value="簡(jiǎn)單請(qǐng)求" onclick="simpleRequest()"/>
</body>
<script>
function simpleRequest() {
$.ajax({
url:'http://edas.aliyun.com/testCors',
type:'get',
success:function (msg) {
$("#test").html(msg);
}
})
}
</script>
配置 hosts
127.0.0.1 edas.aliyun.com
127.0.0.1 edasnext.aliyun.com
為了方便在本地復(fù)現(xiàn)跨域問題,使用同一個(gè)后端,配置了兩個(gè)域名解析,edasnext.aliyun.com作為前端訪問的入口,edas.aliyun.com則作為后端接口的入口,由此構(gòu)建一個(gè)跨域場(chǎng)景。
跨域測(cè)試
img
可以看到,由于當(dāng)前的 springboot 應(yīng)用沒有進(jìn)行跨域配置,所以請(qǐng)求失敗了。
而如果通過 postman 重放這次請(qǐng)求,請(qǐng)求成功:
這個(gè)實(shí)驗(yàn)得出了兩個(gè)結(jié)論:
- 瀏覽器提示跨域請(qǐng)求失敗,服務(wù)端可能已經(jīng)處理完畢,但是由于沒有攜帶 Access 相關(guān)響應(yīng)頭,在到達(dá)瀏覽器時(shí),被拒絕了
- 跨域問題的主體是瀏覽器,服務(wù)端是配合的角色
服務(wù)端跨域配置
如果僅僅是應(yīng)對(duì)簡(jiǎn)單請(qǐng)求模式,完全可以直接給響應(yīng)添加 Access-Control-Allow-Origin響應(yīng)頭,但實(shí)際的跨域全場(chǎng)景,流程比較復(fù)雜,springboot 提供了專門的跨域配置解決該問題:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/testCors")
.allowedOrigins("http://edasnext.aliyun.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
關(guān)于上述 Configuration 中的 CorsRegistry 跨域配置的最佳實(shí)踐,將會(huì)在下文中進(jìn)行詳細(xì)介紹。
再次發(fā)起跨域請(qǐng)求測(cè)試成功
至此簡(jiǎn)單請(qǐng)求模式介紹完畢。
預(yù)檢請(qǐng)求模式
預(yù)檢請(qǐng)求模式相比簡(jiǎn)單請(qǐng)求模式會(huì)多出一個(gè) OPTIONS 請(qǐng)求的流程,這個(gè)行為也是瀏覽器自主產(chǎn)生的。預(yù)檢請(qǐng)求的必要性主要在于更加安全,方便服務(wù)端針對(duì)復(fù)雜跨域請(qǐng)求進(jìn)行自主的校驗(yàn),并且減少了不必要的非正常跨域請(qǐng)求,缺點(diǎn)自然是加大了 CORS 的復(fù)雜度。
預(yù)檢請(qǐng)求成功時(shí),瀏覽器會(huì)接受到預(yù)檢響應(yīng)中的信息,該信息包含了是否允許攜帶 cookie 以及預(yù)檢的緩存時(shí)間,這兩個(gè)參數(shù)都是極其有意義的,前者會(huì)在下文繼續(xù)補(bǔ)充,而后者決定了在一段時(shí)間內(nèi),針對(duì)復(fù)雜請(qǐng)求是否仍要發(fā)送預(yù)檢請(qǐng)求。
預(yù)檢請(qǐng)求模式-快速入門
編寫跨域前端
<div id="test"></div>
<input type="button" value="非簡(jiǎn)單請(qǐng)求" onclick="preflightedRequest()"/>
</body>
<script>
function preflightedRequest() {
$.ajax({
url:'http://edas.aliyun.com/testCors',
type: 'post',
data: JSON.stringify({}),
contentType: 'application/json',
success:function (msg) {
$("#test").html(msg);
}
})
}
</script>
測(cè)試非簡(jiǎn)單請(qǐng)求
由于服務(wù)端已經(jīng)配置過跨域了,能夠配合瀏覽器正常處理預(yù)檢,可以看到瀏覽器先發(fā)送了一次預(yù)檢請(qǐng)求,后發(fā)送了實(shí)際請(qǐng)求。
CORS 跨域配置的最佳實(shí)踐
以 springboot 提供的 CorsRegistry 跨域配置為例,服務(wù)端在處理 CORS 跨域時(shí)一般有以下配置:
corsRegistry.addMapping("/**")
.allowedOrigins("http://edasnext.aliyun.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
其中 allowedOrigins 和 allowCredentials 字段需要格外關(guān)注
- allowedOrigins 表明允許哪些跨域來源允許訪問該服務(wù)端,與 HTTP 請(qǐng)求中的 Origin 請(qǐng)求頭對(duì)應(yīng)
- allowCredentials 表明跨域請(qǐng)求是否可以攜帶 cookie
一個(gè)跨域配置的誤區(qū)是配置 allowedOrigins=* 同時(shí)配置 allowCredentials=true
// 錯(cuò)誤的示例
corsRegistry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
這表明允許任何來源可以進(jìn)行跨域請(qǐng)求,并且允許攜帶 cookie。
瀏覽器的同源策略是基于安全考慮而設(shè)置的約束,避免了 CSRF、XSS 等常見的低成本的攻擊手段,所以并不能簡(jiǎn)單認(rèn)為跨域請(qǐng)求不被瀏覽器攔截就完事大吉了,需要做的是在沒有安全漏洞的前提下保證正常跨域請(qǐng)求能夠訪問成功。
在 springboot 框架下,允許進(jìn)行上述的配置,但是實(shí)際處理請(qǐng)求時(shí),服務(wù)端會(huì)出現(xiàn)報(bào)錯(cuò):
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.] with root cause
這也不見得是一個(gè)最佳實(shí)踐,更加建議在配置時(shí)就給出 ERROR 或者 WARN 配置提示,而不是延遲到運(yùn)行時(shí)報(bào)錯(cuò)。
服務(wù)端跨域的行為并沒有明確地被 w3c 規(guī)范規(guī)定,這導(dǎo)致每個(gè)框架都有自己的配置格式以及表現(xiàn),也有框架默許這樣的錯(cuò)誤配置。或許這些框架在正常情況下,跨域請(qǐng)求不會(huì)被攔截,這種暴風(fēng)雨前的寧靜讓框架使用者感到舒心,但遭遇 CSRF、XSS 等攻擊時(shí),沒有人會(huì)感謝這樣的“方便”。
一些跨域配置的最佳實(shí)踐如下:
- 允許所有來源進(jìn)行跨域請(qǐng)求時(shí),不允許攜帶 cookie:
corsRegistry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(false)
.maxAge(3600);
- 允許指定來源進(jìn)行跨域請(qǐng)求時(shí),攜帶 cookie:
corsRegistry.addMapping("/**")
.allowedOrigins("http://edasnext.aliyun.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
- 允許泛域名匹配時(shí)攜帶 cookie:
corsRegistry.addMapping("/**")
.allowedOriginPatterns("http://*.aliyun.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
當(dāng)子域名無法枚舉時(shí),可以使用這種匹配方式,但不要配置為 http://*.com或者 *
- 針對(duì)指定的服務(wù)端路徑進(jìn)行精細(xì)化的跨域配置
corsRegistry.addMapping("/testCors")
.allowedOrigins("http://edasnext.aliyun.com")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
網(wǎng)關(guān)中的跨域配置
在稍微復(fù)雜的系統(tǒng)架構(gòu)中,往往會(huì)引入網(wǎng)關(guān)組件,跨域配置也是非常適合轉(zhuǎn)移到網(wǎng)關(guān)的一項(xiàng)配置,可以將跨域的復(fù)雜度和安全性保障,收斂到網(wǎng)關(guān)這一個(gè)單一組件中。
網(wǎng)關(guān)處理跨域問題,請(qǐng)求鏈路為:瀏覽器 -> 網(wǎng)關(guān) -> 服務(wù)端,根據(jù)前面的結(jié)論,同源策略只存在于瀏覽器發(fā)起的請(qǐng)求,所以網(wǎng)關(guān)向服務(wù)端的請(qǐng)求時(shí)不存在跨域問題,只需要考慮瀏覽器 -> 網(wǎng)關(guān)這一跳。
以 Spring Cloud Gateway 為例,其提供了開箱即用的跨域能力:
spring:
cloud:
gateway:
fail-on-route-definition-error: false
routes:
- id: r-cors
predicates:
- Path=/testCors
uri: http://edas.aliyun.com
order: 1000
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: 'http://edasnext.aliyun.com'
allowCredentials: true
allowedMethods:
- '*'
allowedHeaders:
- '*'
maxAge: 3600
add-to-simple-url-handler-mapping: true
在上述示例中,我們配置了一個(gè) r-cors 路由,轉(zhuǎn)發(fā)到本地的后端服務(wù),并且配置了全局的跨域策略,和服務(wù)端的配置格式類似。
add-to-simple-url-handler-mapping的含義是:如果路由沒有配置 OPTIONS 匹配,可以打開此開關(guān),讓預(yù)檢請(qǐng)求成功返回,不會(huì)因?yàn)榭缬虻念A(yù)檢而導(dǎo)致路由訪問不通。推薦打開此配置,這樣在配置路由時(shí),可以只需要關(guān)注業(yè)務(wù)真正的請(qǐng)求方法,而不需要考慮跨域問題。
在網(wǎng)關(guān)配置跨域后,后端服務(wù)就可以不用處理跨域問題了,去除服務(wù)端的 corsRegistry 配置,并且增加請(qǐng)求 Spring Cloud Gateway 網(wǎng)關(guān)的測(cè)試用例(本地將網(wǎng)關(guān)啟動(dòng)在 8080 端口):
<div id="test">
</div>
<input type="button" value="簡(jiǎn)單請(qǐng)求-網(wǎng)關(guān)" onclick="simpleRequestPassbyGateway()"/>
<input type="button" value="非簡(jiǎn)單請(qǐng)求-網(wǎng)關(guān)" onclick="preflightedRequestPassbyGateway()"/>
</body>
<script>
function simpleRequestPassbyGateway() {
$.ajax({
url:'http://127.0.0.1:8080/testCors',
type:'get',
data:{},
success:function (msg) {
$("#test").html(msg);
}
})
}
function preflightedRequestPassbyGateway() {
$.ajax({
url:'http://127.0.0.1:8080/testCors',
type: 'post',
data: JSON.stringify({ }),
contentType: 'application/json',
success:function (msg) {
$("#test").html(msg);
}
})
}
</script>
simpleRequestPassbyGateway,preflightedRequestPassbyGateway 會(huì)經(jīng)過網(wǎng)關(guān)路由轉(zhuǎn)發(fā)至后端服務(wù),根據(jù)同源策略 origin=127.0.0.1:8080,而前端域名為 http://edasnext.aliyun.com,這同樣是一個(gè)跨域請(qǐng)求。
至此,只需要在網(wǎng)關(guān)進(jìn)行統(tǒng)一配置跨域,后端服務(wù)就不用關(guān)注跨域問題了。所以,跨域的支持也是主流網(wǎng)關(guān)的常用功能之一。
網(wǎng)關(guān)跨域和服務(wù)端跨域共存的問題
試想一下,如果服務(wù)端配置了跨域,同時(shí)網(wǎng)關(guān)配置跨域,表現(xiàn)會(huì)如何呢?這種場(chǎng)景一定不會(huì)少,例如一個(gè)原本配置了跨域的應(yīng)用,需要接入到網(wǎng)關(guān),一定會(huì)存在兩份跨域配置共存的時(shí)機(jī)。還是延續(xù)上述的跨域用例,打開服務(wù)端的 CorsRegistry 配置,再發(fā)送跨域請(qǐng)求至網(wǎng)關(guān),會(huì)得到如下報(bào)錯(cuò):
Access to XMLHttpRequest at 'http://127.0.0.1:8080/testCors' from origin 'http://edasnext.aliyun.com' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values 'http://edasnext.aliyun.com, http://edasnext.aliyun.com', but only one is allowed.
這是因?yàn)榫W(wǎng)關(guān)和服務(wù)端都會(huì)給響應(yīng)追加跨域請(qǐng)求頭,導(dǎo)致瀏覽器無法識(shí)別。
一個(gè)比較簡(jiǎn)單的開源解決方案是在網(wǎng)關(guān)上配置一個(gè)過濾器:
spring:
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
此方案可以去除重復(fù)的跨域響應(yīng)頭。
在共存階段后完成流量遷移,最后建議還是去除服務(wù)端的配置。
