瀏覽器中的跨域問題與 CORS
Access to XMLHttpRequest at 'xxx' from origin 'xxx' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
什么是跨域?[1]
跨域,這或許是前端面試中最常碰到的問題了,大概因?yàn)榭缬騿栴}是瀏覽器環(huán)境中的特有問題,而且隨處可見,如同蚊子不僅盯你肉而且處處圍著你轉(zhuǎn)讓你心煩?!改憧?,在服務(wù)器發(fā)起 HTTP 請求就不會有跨域問題的」。
當(dāng)談到跨域問題的解決方案時(shí),最流行也最簡單的當(dāng)屬 CORS 了。
CORS
CORS 即跨域資源共享 (Cross-Origin Resource Sharing, CORS)。簡而言之,就是在服務(wù)器端的響應(yīng)中加入幾個(gè)標(biāo)頭,使得瀏覽器能夠跨域訪問資源。
這個(gè)響應(yīng)頭的字段設(shè)置就是 Access-Control-Allow-Origin: *以下是最簡單的一個(gè) CORS 請求
- GET / HTTP/1.1
- Host: shanyue.tech
- Origin: http://shanyue.tech
- User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: *
- Content-Type: text/plain; charset=utf-8
- Content-Length: 12
- Date: Wed, 08 Jul 2020 17:03:44 GMT
- Connection: keep-alive
預(yù)請求與 Options
當(dāng)一個(gè)請求跨域且不是簡單請求時(shí)就會發(fā)起預(yù)請求,也就是 Options。如果沒有預(yù)請求,萬一有一個(gè)毀滅性的 POST 跨域請求直接執(zhí)行,雖然最后告知瀏覽器你沒有跨域權(quán)限,但是損失已造成,豈不虧大的。
以下條件構(gòu)成了簡單請求:
- Method: 請求的方法是 GET、POST 及 HEAD
- Header: 請求頭是 Content-Type (有限制)、Accept-Language、Content-Language 等
- Content-Type: 請求類型是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain非簡單請求一般需要開發(fā)者主動構(gòu)造,在項(xiàng)目中常見的 Content-Type: application/json 及 Authorization:
為典型的「非簡單請求」。與之有關(guān)的三個(gè)字段如下:
- Access-Control-Allow-Methods: 請求所允許的方法, 「用于預(yù)請求 (preflight request) 中」
- Access-Control-Allow-Headers: 請求所允許的頭,「用于預(yù)請求 (preflight request) 中」
- Access-Control-Max-Age: 預(yù)請求的緩存時(shí)間
寫一個(gè) CORS Middleware
既然 CORS 原理如此簡單,那就拿起鍵盤寫一個(gè)簡單的 CORS 中間件吧,CORS 大致是設(shè)置幾個(gè)響應(yīng)頭吧
❝關(guān)于 cors 的響應(yīng)頭有哪些?[2]❞
「關(guān)于 CORS 的設(shè)置即是對 CORS 相關(guān)響應(yīng)頭的設(shè)置,因此了解這些 headers 至關(guān)重要。無論對于配置的生產(chǎn)者和消費(fèi)者,及后端和前端而言,都應(yīng)該掌握!」
以下是關(guān)于 CORS 相關(guān)的 response headers 及其釋義
- Access-Control-Allow-Origin: 可以把資源共享給那些域名,支持 * 及 特定域名
- Access-Control-Allow-Credentials: 請求是否可以帶 cookie
- Access-Control-Allow-Methods: 請求所允許的方法, 「用于預(yù)請求 (preflight request) 中」
- Access-Control-Allow-Headers: 請求所允許的頭,「用于預(yù)請求 (preflight request) 中」
- Access-Control-Expose-Headers: 那些頭可以在響應(yīng)中列出
- Access-Control-Max-Age: 預(yù)請求的緩存時(shí)間
而關(guān)于 CORS 的中間件即是使用默認(rèn)值與配置來設(shè)置這些頭,如 koa/cors 需要傳遞以下參數(shù)。
- /**
- * CORS middleware
- *
- * @param {Object} [options]
- * - {String|Function(ctx)} origin `Access-Control-Allow-Origin`, default is request Origin header
- * - {String|Array} allowMethods `Access-Control-Allow-Methods`, default is 'GET,HEAD,PUT,POST,DELETE,PATCH'
- * - {String|Array} exposeHeaders `Access-Control-Expose-Headers`
- * - {String|Array} allowHeaders `Access-Control-Allow-Headers`
- * - {String|Number} maxAge `Access-Control-Max-Age` in seconds
- * - {Boolean|Function(ctx)} credentials `Access-Control-Allow-Credentials`, default is false.
- * - {Boolean} keepHeadersOnError Add set headers to `err.header` if an error is thrown
- * @return {Function} cors middleware
- * @api public
- */
- // Example
- app.use(cors())
CORS 如何設(shè)置多域名
由上,貌似很簡單,只需要服務(wù)端設(shè)置一下 Access-Control-Allow-Origin 就可以輕松解決問題,但其中的坑有可能比你想象地要多很多!
先說回 Access-Control-Allow-Origin,它所允許的值只有兩個(gè)
- *: 所有域名
- shanyue.tech: 特定域名
此時(shí),新問題來了:
❝CORS 如果需要指定多個(gè)域名怎么辦[3]❞
「如果使用 Access-Control-Allow-Origin: *,則所有的請求不能夠攜帶cookie」,因此這種方案被擯棄。
因此這個(gè)問題需要寫代碼來解決,根據(jù)請求頭中的 Origin 來設(shè)置響應(yīng)頭 Access-Control-Allow-Origin
- 如果請求頭不帶有 Origin,證明未跨域,則不作任何處理
- 如果請求頭帶有 Origin,證明跨域,根據(jù) Origin 設(shè)置相應(yīng)的 Access-Control-Allow-Origin:
// 獲取 Origin 請求頭const requestOrigin = ctx.get('Origin');// 如果沒有,則跳過if (!requestOrigin) { return await next();}// 設(shè)置響應(yīng)頭ctx.set('Access-Control-Allow-Origin', requestOrigin)
「但此時(shí)會出現(xiàn)一個(gè)新的問題:緩存」
CORS 與 Vary: Origin
在討論與 Vary 關(guān)系時(shí),先拋出一個(gè)問題:
❝如何避免 CDN 為 PC 端緩存移動端頁面[4]❞
假設(shè)有兩個(gè)域名訪問 static.shanyue.tech 的跨域資源
- foo.shanyue.tech,響應(yīng)頭中返回 Access-Control-Allow-Origin: foo.shanyue.tech
- bar.shanyue.tech,響應(yīng)頭中返回 Access-Control-Allow-Origin: bar.shanyue.tech
看起來一切正常,但平靜的水面下波濤暗涌:
「如果 static.shanyue.tech 資源被 CDN 緩存,bar.shanyue.tech 再次訪問資源時(shí),因緩存問題,因此此時(shí)返回的是 Access-Control-Allow-Origin: foo.shanyue.tech,此時(shí)會有跨域問題」
此時(shí),Vary: Origin 就上場了,代表為不同的 Origin 緩存不同的資源,這在各個(gè)服務(wù)器端 CORS 中間件也能體現(xiàn)出來,如以下幾段代碼
此處是一段 koa 關(guān)于 CORS 的處理函數(shù): 詳見 koajs/cors[5]
- return async function cors(ctx, next) {
- // If the Origin header is not present terminate this set of steps.
- // The request is outside the scope of this specification.
- const requestOrigin = ctx.get('Origin');
- // Always set Vary header
- // https://github.com/rs/cors/issues/10
- ctx.vary('Origin');
- }
此處是一段 Go 語言關(guān)于 CORS 的處理函數(shù): 詳見 rs/cors[6]
- func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
- headers := w.Header()
- origin := r.Header.Get("Origin")
- // Always set Vary, see https://github.com/rs/cors/issues/10
- headers.Add("Vary", "Origin")
- }
進(jìn)一步改進(jìn)相關(guān)代碼:
- // 獲取 Origin 請求頭
- const requestOrigin = ctx.get('Origin');
- // 不管有沒有跨域都要設(shè)置 Vary: Origin
- ctx.set('Vary', 'Origin')
- // 如果沒有設(shè)置,說明沒有跨域,跳過
- if (!requestOrigin) {
- return await next();
- }
- // 設(shè)置響應(yīng)頭
- ctx.set('Access-Control-Allow-Origin', requestOrigin)
「那此時(shí)是不關(guān)于 CORS 的問題就解決了?從中間件處理層面是這樣的,但仍然有一些服務(wù)端中間件使用問題及瀏覽器問題」
HSTS 與 CORS
HSTS (HTTP Strict Transport Security) 為了避免 HTTP 跳轉(zhuǎn)到 HTTPS 時(shí)遭受潛在的中間人攻擊,由瀏覽器本身控制到 HTTPS 的跳轉(zhuǎn)。如同 CORS 一樣,它也是有一個(gè)服務(wù)器的響應(yīng)頭來控制
- Strict-Transport-Security: max-age=5184000
此時(shí)瀏覽器訪問該域名時(shí),會使用 307 Internal Redirect,無需服務(wù)器干涉,自動跳轉(zhuǎn)到 HTTPS 請求。
「如果前端訪問 HTTP 跨域請求,此時(shí)瀏覽器通過 HSTS 跳轉(zhuǎn)到 HTTPS,但瀏覽器不會給出相應(yīng)的 CORS 響應(yīng)頭部,就會發(fā)生跨域問題?!?/p>
- GET / HTTP/1.1
- Host: shanyue.tech
- Origin: http://shanyue.tech
- User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
- Access to XMLHttpRequest at 'xxx' from origin 'xxx' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
服務(wù)器異常處理與跨域異常
當(dāng)與其他中間件一起工作時(shí),也有可能出現(xiàn)問題,由于不正確的執(zhí)行順序也可能導(dǎo)致跨域失敗。
假設(shè)有一個(gè)參數(shù)校驗(yàn)中間件,置于 CORS 中間件上方,由于校驗(yàn)失敗,并未穿過 CORS 中間件,在前端會報(bào)錯(cuò)跨域失敗,真正的參數(shù)校驗(yàn)問題掩蓋其中。
- const Koa = require('koa')
- const app = new Koa()
- const cors = require('@koa/cors')
- // 異常處理中間件
- app.use(async (ctx, next) => {
- try {
- await next()
- } catch (e) {
- ctx.body = 'hello, error'
- }
- })
- // 某一個(gè)特定時(shí)刻肯定會報(bào)錯(cuò)的中間件
- app.use(async (ctx, next) => {
- throw new Error('hello, world')
- })
- // CORS 中間件
- app.use(cors())
- app.listen(3000)
總結(jié)
本篇文章介紹了跨域問題及其相應(yīng)的 CORS 解決方案,并列出了若干細(xì)節(jié)問題。
CORS 通過服務(wù)器端設(shè)置若干響應(yīng)頭來正常工作
Access-Control-Allow-Origin: * 無法攜帶 Cookie,因此以此為多域名跨域設(shè)置有缺陷
服務(wù)器端通過響應(yīng)頭 Origin 來判斷是否為跨域請求,并以此設(shè)置多域名跨域,但要加上 Vary: Origin在編碼過程中要注意 HSTS 配置及服務(wù)器的中間件順序帶來的潛在風(fēng)險(xiǎn)
Reference
[1]什么是跨域?:
https://q.shanyue.tech/fe/js/216.html[2]關(guān)于 cors 的響應(yīng)頭有哪些?:
https://q.shanyue.tech/base/http/328.html[3]CORS 如果需要指定多個(gè)域名怎么辦:
https://q.shanyue.tech/base/http/364.html[4]如何避免 CDN 為 PC 端緩存移動端頁面:
https://q.shanyue.tech/base/http/330.html[5]koajs/cors:
https://github.com/koajs/cors/blob/master/index.js#L54[6]rs/cors:
https://github.com/rs/cors/blob/be1c7e127af9fce006600894df5c5731d99cdc82/cors.go#L268
本文轉(zhuǎn)載自微信公眾號「全棧成長之路」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系全棧成長之路公眾號。