项目源码:https://github.com/trp1119/cross-domain
1 跨域问题的出现
1.1 什么是同源策略
同源策略(Same origin policy
)是浏览器最核心也最基础的安全功能,同源策略会阻止一个域的 javascript
脚本和另外一个域的内容进行交互。所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol
)、主机(host
)和端口号(port
)。[1]
1.2 什么是跨域请求(非同源策略请求)
跨域请求,即非同源策略请求,指当前发起请求的域与该请求指向的资源所在的域不一致。[1]
当前页面url | 被请求页面url | 是否跨域 | 原因 |
---|---|---|---|
http://www.test.com | http://www.test.com/index.html | 否 | 同源(协议、域名、端口号相同) |
http://www.test.com/ | https://www.test.com/index.html | 跨域 | 协议不同(http/https) |
http://www.test.com/ | http://www.baidu.com/ | 跨域 | 主域名不同(test/baidu) |
http://www.test.com/ | http://blog.test.com/ | 跨域 | 子域名不同(www/blog) |
http://www.test.com:8080/ | http://www.test.com:7001/ | 跨域 | 端口号不同(8080/7001) |
1.3 跨域请求发生场景
- 在现代前端开发中,我们经常需要调用第三方的服务接口(例如
mock server
、fake api
),随着专业化分工的出现有很多专业的信息服务提供商为前端开发者提供各类接口,这种情况下就需要进行跨域请求。 - 在前后端分离的项目中,前端后端分属于不同的服务跨域问题在采用这种架构的时候就存在,而且现在很多项目都采用这种前后分离的方式。
1.4 同源策略带来的跨域请求限制
- 无法读取非同源网页的
Cookie
、LocalStorage
和IndexedDB
- 无法接触非同源网页的
DOM
- 无法向非同源地址发送
AJAX
请求[1]
举例
数据服务器(server_database
)配置(5000
端口)
/**
* 数据服务器
*/
let express = require('express'),
app = express()
app.listen(5000, () => {
console.log('数据服务器启动成功,运行在5000端口')
})
app.get('/queryInfo', (req, res) => {
let data = {
code: 0,
msg: '非同源数据!'
}
res.send(data)
})
客户端(静态资源)服务器(server_static
)配置(8000
端口)
/**
* 客户端(静态资源)服务器
*/
let express = require('express'),
app = express()
app.listen(8000, () => {
console.log('客户端(静态资源)服务器启动成功,运行在8000端口')
})
app.get('/queryInfo', (req, res) => {
res.send({
code: 0,
msg: '同源数据!'
})
})
app.use(express.static('./static'))
客户端数据请求
客户端数据请求结果
同源请求下,得到服务器返回数据
{"code":0,"msg":"同源数据!"}
非同源请求(跨域请求)下,浏览器报错
Failed to load http://localhost:5000/queryInfo: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8000' is therefore not allowed access.
tips:在未经允许的情况下,浏览器禁止 js
读取另一个域名的内容。但浏览器并不阻止向另一个域名发送请求。
跨域请求(非同源策略请求)限制为浏览器限制,非服务器限制。无论是否是跨域请求,服务器均会返回数据。
2 为什么会有跨域限制
-
保护cookie、LocalStorage 和 IndexedDB
cookie
中存着sessionID
(登录凭证)。当用户访问恶意网站时,如果没有同源策略,那么这个网站就可以通过js
访问document.cookie
得到用户关于各个网站的sessionID
,如果这个sessionID
在有效期内,恶意网站就可以利用sessionID
登录各个网站,获取用户其他信息。[2]
-
保护DOM操作
恶意网站通过
iframe
加载支付宝页面,当用户进入恶意网站后,误以为是支付宝官方页面,输入用户名、密码等信息。如果没有同源策略,恶意网站就可以通过DOM操作
获取到用户输入值,从而控制用户账户。[2]
-
限制 ajax操作
cookie 工作机制
客户向 A 网站的服务器发送登录请求,并携带账号密码数据
A 网站的服务器校验账号密码正确后,返回响应并给本地添加了cookie
之后客户再次向 A 网站发起请求会自动带上A网站存储在本地的cookie
A 网站的服务器从cookie中获取账号密码数据后,返回登陆成功界面
用户登录过支付宝后,支付宝在本地设置了 cookie
信息。如果没有同源策略,当用户访问恶意网站时,恶意网站利用存储在本地的 cookie
等信息通过 ajax
向支付宝发起登录请求,从 ajax
回调中解析到用户数据信息。[3]
3 八种跨域解决方案
3.1 JSONP
3.1.1 JSONP 可用前提
浏览器安全性和方便性是成反比的,十位数的密码提高了安全性,但是不方便记忆。同样,同源策略提升了 Web
前端的安全性,但牺牲了Web拓展上的灵活性。
设想若把 html
、js
、css
、flash
,image
等文件全部布置在一台服务器上,小型网站这样还可以,大中型网站如果这样做服务器无法承受。为解决服务器冗余,在实型前后端分离,静态资源服务器、图片资源服务器、视频资源服务器等拆分后,虽然系统变的更加灵活,但受制于浏览器同源策略限制,不同服务器之间通信受到限制。虽然保证了安全,Web
方便性大打折扣。
所以,现代浏览器在安全性和可用性之间选择了一个平衡点。在遵循同源策略的基础上,选择性地为同源策略“开放了后门”。 例如 script
、 img
、link
、iframe
等标签,都允许垮域引用资源,严格说这都是不符合同源要求的。(当然,用户只能是引用这些资源而已,并不能读取这些资源的内容。例如在自己域内可以读取百度 logo
图片,但无法读取到该数据的二进制资源。)[4]
利用浏览器允许 script
标签跨域引用资源的特性,形成了一种非正式传输协议,JSONP
。
3.1.2 一般 JSONP 使用方法及其实现原理
JSONP
( JSON with Padding
),一种非官方跨域数据交互协议。在使用时,用户传递一个 callback
参数给服务端,然后服务端返回数据时会将这个 callback
参数作为函数名来包裹住 JSON
数据,这样客户端就可以随意定制自己的函数来自动处理返回数据。[5]
3.1.2.1 JSONP 的使用
JSONP 客户端调用方法
JSONP 服务器配置
/**
* 数据资源服务器
*/
let express = require('express'),
app = express()
app.listen(5000, () => {
console.log('数据服务器启动成功,运行在5000端口')
})
app.get('/queryInfo', (req, res) => {
let data = {
code: 0,
msg: '非同源数据!'
}
// JSONP 跨域数据返回
let fn = req.query.callback // 获取客户端传递的函数名,注意,此处 callback 要与前端协商设置
res.send(`${fn}(${data})`) // 返回指定格式的内容,函数名(数据) 这种格式
})
返回数据
3.1.2.2 JSONP 实现跨域请求原理
注意:客户端定义的必须是全局函数,因为浏览器中收到服务器返回的函数执行只有在全局函数下才能运行。
3.1.3 ajax 下使用 JSONP及其原理
JSONP
是非官方跨域数据交互协议,但 ajax
对其进行了封装,可采用 ajax
发起 jsonp
跨域请求。(axios
无 jsonp
请求方式)
3.1.3.1 使用 ajax 进行 JSONP 跨域请求
JSONP 客户端调用方法
接口调用
返回数据
3.1.3.2 ajax 实现 JSONP 跨域请求原理
ajax 对 JSONP 的封装依然才用的是 JSONP 实现原理,即通过动态创建 script 标签,然后拼装数据后发起请求。具体实现可参考 3.1.4 封装一个简单的 JSONP 实现返回 Promise。
3.1.4 封装一个简单的 JSONP 实现返回 Promise
JSONP 封装
;(function anonymous(window) {
/**
* JSONP 方法
* url 请求的接口地址
* options 配置项
* jsonp: 'callback'(默认值)
* jsonpCallback: 随机生成的全局函数/自定义全局函数名
* timeout: 3000(默认值)
*/
let jsonp = function (url, options = {}) {
// 返回 Promise
return new Promise((resolve, reject) => {
// 验证参数合法性
if (typeof url === 'undefined') {
reject('url必须传递!')
return
}
// 发送 jsonp 请求
let SCRIPT = document.createElement('script'),
CALL_BACK = options.jsonp || 'callback',
FN_NAME = options.jsonpCallback || `JSONP${new Date().getTime()}`,
SCRIPT.src = `${url}${url.indexOf('?') >= 0 ? '&' : '?'}${CALL_BACK}=${FN_NAME}&_${new Date().getTime()}`
document.body.appendChild(SCRIPT)
// 成功或失败后执行的函数
window[FN_NAME] = function (result) {
document.body.removeChild(SCRIPT)
window[FN_NAME] = null
resolve(result)
}
})
}
if (typeof module !== 'undefined' && module.exports !== 'undefined') {
module.exports = {
jsonp
}
}
window.jsonp = jsonp
})(typeof window === 'undefined' ? global : window)
// 判断正在不同的环境下去,让 window 代表不同的全局对象,浏览器环境下就是 window,node 环境下执行代码就是 global
使用自行封装的 JSONP 发起跨域请求
接口调用
返回数据
3.1.5 JSONP 不足
-
JSONP
由于采用script
资源文件请求,而资源请求为GET
请求,故仅在GET
请求中使用JSONP
跨域请求。 - 使用
ajax
请求网站,而服务器返回的JSONP
callback
是恶意执行代码,导致返回浏览器后会自动执行恶意代码,威胁数据安全。(XSS
攻击)
3.2 CORS 跨域资源共享
CORS
(Cross-Origin Resource Sharing
),跨域资源共享
CORS
需要浏览器和服务器同时支持,才可以实现跨域请求,目前几乎所有浏览器都支持 CORS
,IE
则不能低于 IE10
。CORS
的整个过程都由浏览器自动完成,前端无需做任何设置,跟平时发送 ajax
请求并无差异。所以,实现 CORS
的关键在于服务器,只要服务器实现 CORS
接口,就可以实现跨域通信。[6]
3.2.1 CORS 跨域配置方式
服务器未配置时限制跨域
由图中可以看出,浏览器不允许跨域原因已指出,No “Access-Control-Allow-Origin” header
,故可在服务端进行相关头部信息配置以实现跨域请求。6
客户端发送请求
服务端配置
/**
* 数据资源服务器
*/
let express = require('express'),
app = express()
app.listen(5000, () => {
console.log('数据服务器启动成功,运行在5000端口')
})
// 基于CORS设置允许跨域请求
app.use((req, res, next) => {
// 允许哪些源可以向这个服务器发送AJAX请求(通配符是 '*',表示允许所有的源,也可以单独设置某个源,'http://localhost:8000',这样只允许 http://localhost:8000 的请求)
// 不使用通配符是为了保证接口和数据的安全,不能让所有的源都能访问。而且一旦设置了允许携带凭证过来,则设置 '*' 通配符会被报错,此时只能设置具体的源!且只能设置一个允许访问的源。
res.header('Access-Control-Allow-Origin', '*')
// 是否允许跨域的时候携带凭证(例如 cookie 凭证,true 为允许,false 为不允许,设置为 false,客户端和服务器之间不会传递 cookie,这样 session 存储就失效了)(session 之所以有用是因为客户端从 cookie 中取 sid ,即将sid通过 cookie 传递给服务器进行校验)
// 一般都设置为 true
res.header('Access-Control-Allow-Credentials', true)
// 允许的请求头部(哪些头部信息是合法的)
res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-Width, Cookie')
// 允许的请求方式(一定要有 OPTIONS)
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, HEAD, OPTIONS')
// 设置 OPTIONS 请求目的:我们吧这个请求当做一个试探性请求,当客户端需要向服务器发送请求的时候,首先发送一个 OPTIONS 请求,服务器接受到是 OPTIONS 请求,看一下是否允许跨域,允许返回成功。如果服务器不允许跨域,则客户端会出现跨域请求不允许的错误。如果客户端检测到不允许跨域,则后续的请求都不再进行。 =》 客户端 axios 框架就是这样处理的,自己写的没有写 OPTIONS 请求。
req.method === 'OPTIONS' ? res.send('CURRENT SERVICES SUPPORT CROSS DOMAIN REQUESTS!') : next()
// next() 为 express 中间件语法
next()
})
app.get('/queryInfo', (req, res) => {
let data = {
code: 0,
msg: '非同源数据!'
}
// CORS 跨域数据返回
res.send(data)
})
接口调用
返回数据
3.2.2 CORS 跨域配置介绍
3.2.2.1 Access-Control-Allow-Origin
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Origin', 'http://localhost:8000')
允许哪些源可以向这个服务器发送数据请求(通配符是 '*'
,表示允许所有的源,也可以单独设置某个源,如 'http://localhost:8000'
,这样只允许 http://localhost:8000
的请求)。
不使用通配符 '*'
是为了保证接口和数据的安全,即不能让所有的源都能访问。而且一旦设置了允许携带凭证过来,则设置 '*' 通配符会被报错,此时只能设置具体的源!且只能设置一个允许访问的源。 7
8
3.2.2.2 Access-Control-Allow-Credentials
res.header('Access-Control-Allow-Credentials', true)
是否允许跨域的时候携带凭证(例如 cookie
凭证,设置为 true
为允许携带 cookie
凭证,false
为不允许。设置为 false
客户端和服务器之间不会传递 cookie
,这样 session
存储就失效了)(session
之所以有用是因为客户端从 cookie
中取 sid
,即将 sid
通过 cookie
传递给服务器进行校验)
携带凭证需要客户端设置 withCredentials: true
,此时,若 Access-Control-Allow-Origin
设置为通配符 '*'
,即 res.header('Access-Control-Allow-Origin', '*')
,浏览器会报错(因为任何域都携带凭证请求会影响安全)。只允许设置域形式。
同样,若 Access-Control-Allow-Origin
设置多个 域,即 res.header('Access-Control-Allow-Origin', 'http://localhost:8000, http://localhost:8001')
,浏览器会报错。
即在客户端携带凭证请求情况下只能设置允许一个域的请求。 7
8
未设置 withCredentials
为 true
时的请求头(无 cookie
)
设置 withCredentials
为 true
时的请求头(有 cookie
)
3.2.2.3 Access-Control-Allow-Headers
res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-Width, Cookie')
设置允许的请求头部信息,即哪些头部信息是合法的。
3.2.2.4 Access-Control-Allow-Methods
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, HEAD, OPTIONS')
允许的请求方式(一定要有 OPTIONS
)。
3.2.3 CORS 跨域不足
若携带凭证发起请求,CORS
只能指定一个允许源,不能使用通配符和指定多个源。
3.3 node 作为中间件代理
Nodejs
的 request
模块是服务端发起请求的工具包,可在本服务器向其他域的服务器发起请求。使用前需安装 request
插件。 9
yarn add request
客户端发送请求
客户端(静态资源)服务器端配置
/**
* 客户端(静态资源)服务器
*/
let express = require('express'),
app = express()
request = require('request')
app.listen(9000, () => {
console.log('静态资源服务器启动成功,运行在9000端口')
})
app.get('/queryInfo', (req, res) => {
// 在服务器向其他域发起请求
request('http://localhost:5000/queryInfo', (err, response, body) => {
res.send(body)
})
})
app.use(express.static('./static'))
接口调用
返回数据
3.4 http proxy 代理
proxy
只是一层代理,用于把指定 path
代理去数据服务器提供的地址,他的背后是由 node server
提供服务的。
同源策略限制是浏览器进行限制的,服务器间相互数据请求并不受浏览器同源策略限制。
设置代理后,当客户端请求某跨域接口时,实际请求的是客户端所在服务器某个接口, 当客户端服务器收到客户端请求时,会根据代理设置,通过服务器间通信请求数据服务器的数据(跨域数据),请求到数据后,再将数据通过客户端服务器返回给客户端。
这样,客户端请求的是自身服务器接口,而不是跨域接口,不会受同源策略限制,实现跨域。 10
3.4.1 proxy 跨域设置
3.4.1.1 webpack 中 proxy 设置
webpack
dev-server
使用了非常强大的 http-proxy-middleware
包用于解决跨域请求。
在 webpack.config.js
文件中进行配置。 11
简单配置
devServer: {
proxy: {
"/queryInfo": "http://localhost:5000"
}
}
复杂配置
devServer: {
proxy: {
'/queryInfo': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
客户端发送请求
接口调用
返回数据
3.4.1.2 vue 中 proxy 设置
在 vue.config.js
中进行配置。 12
简单配置
简单配置,这样配置后全局接口都会被代理到 http://localhost:5000
module.exports = {
// 简单配置
devServer: {
proxy: 'http://localhost:5000'
}
}
复杂配置
如果要配置部分接口代理,或进行 https
支持等设置,可进行复杂配置。
module.exports = {
// 复杂配置
devServer: {
proxy: {
'/queryInfo': {
target: 'http://localhost:5000',
ws: true,
changeOrigin: true
},
}
}
}
客户端发送请求
接口调用
返回数据
3.4.1.3 react 中 proxy 设置
简单配置
可在 package.json
中进行简单配置,这样配置后全局接口都会被代理到 http://localhost:5000
。13
"proxy": "http://localhost:5000"
复杂配置
如果要配置部分接口代理,或进行 https
支持等设置,可在 src
目录下新建 setupProxy.js
文件进行复杂配置。
使用此配置需先安装 http-proxy-middleware
插件。 13
yarn add http-proxy-middleware
// setupProxy.js 设置
const proxy = require('http-proxy-middleware')
module.exports = function(app) {
app.use(
'/queryInfo',
proxy({
target: 'http://localhost:5000',
changeOrigin: true,
})
)
}
客户端发送请求
import axios from 'axios'
// axios.get('http://localhost:5000/queryInfo').then((res) => {
// console.log(res)
// })
axios.get('/queryInfo').then((res) => {
console.log(res)
})
接口调用
返回数据
3.4.2 proxy 缺点
仅在本地开发环境中使用,由于线上使用的是打包后的静态文件,故需要线上环境服务器配置以支持跨域。
3.5 nginx 反向代理
使用 nginx
启动客户端服务(http://localhost:7000)跨域请求服务端数据(http://localhost:5000/queryInfo),此时受浏览器同源策略限制,浏览器会报错。 14
客户端发送请求
客户端服务器配置
可在 niginx
服务器文件夹 conf/nginx.conf
下进行代理配置。
server {
listen 7000; // 端口号设置
server_name localhost;
location / {
root html/test; // 静态资源文件夹
index index.html index.htm;
}
location /queryInfo { // 跨域代理设置
proxy_pass http://localhost:5000;
}
}
客户端发送请求
设置后,客户端 axios 请求需改为 axios.get('/queryInfo')
以形成访问同源接口样式。
接口调用
返回数据
3.6 window.name
3.6.1 window.name 特性
页面在浏览器端展示的时候,总能在控制台拿到一个全局变量 window
,该变量有一个 name
属性,其有以下特征: 15
- 每个浏览器窗口都有独立的
window.name
与之对应。 - 在一个浏览器窗口的生命周期中(被关闭前),窗口载入的所有页面同时共享一个
window.name
,每个页面对window.name
都有读写的权限。 -
window.name
一直存在与当前窗口,即使是有新的页面载入也不会改变window.name
的。 -
window.name
可以存储不超过2M
的数据,数据格式按需自定义。
举例
在 C页面(http://localhost:5000/window.name/C.html)请求同源服务器,获取到数据并赋值给window.name。
此时,在同一浏览器窗口,将链接改为非同源的 A页面(http://localhost:8000/window.name/A.html),发现此时仍可以通过 window.name 拿到数据。
利用这一点,可以试图在 A页面 用 iframe
加载 C页面,然后取到其 window.name
中的值。
运行发现浏览器进行了跨域请求限制。
3.6.2 利用 window.name 进行跨域
因为 A页面 与 C页面 一直存在于同一浏览器窗口内,window.name
值一直存在,所以可以使用无任何内容的 中间页面 proxy.html
(与 A页面 同源),在 C页面 加载后,将 iframe
的 src
值更改为中间页面 proxy.html
,此时,proxy.html
页面的 window.name
值与 C页面 是一致的。由于 A页面 与 中间页面 proxy.html 同源,故 A页面 此时可以取到 window.name
值。 15
3.7 document.domain
3.7.1 document.domain 跨域使用
document.domain
用来得到当前网页的域名两个文档,只有在 document.domain
都被设定为同一个值,表明他们打算协作;或者都没有设定 document.domain
属性并且 url
的域是一致的,这两种条件下,一个文档才可以去访问另一个文档。
如果不是因为这个特殊的策略,每一个站点都会成为他的子域的 XSS
攻击的对象(例如,http://a.test.com 可以被来自 http://b.test.com 站点的恶意文件攻击)。
利用两个文档 document.domain
相同即可协作的特性,在 A页面 使用 iframe
加载 B页面,并将 A页与B页面设置相同的 document.domain
,进行跨域获取数据。 16
A页面
// A页面链接 http://a.test.com:5000/document.domain/A.html
B页面
返回数据
3.7.2 document.domain 的缺点
在根域范围内,浏览器允许把 domain
属性的值设置为它的上一级域。例如,在 a.test.com
域内,可以把domain
设置为 test.com
。 16
所以 document.domain
只能处理父域相同,子域不同的情况。
3.8 postMessage
受浏览器跨域限制,非同源的页面无法进行通信,window.postMessage()
方法提供了一种受控机制来规避此限制。window.postMessage()
方法可以安全地实现 Window
对象之间的跨域通信。例如,在一个页面和它生成的弹出窗口之间,或者是页面和嵌入其中的 iframe
之间。
一般来说,一个窗口可以获得对另一个窗口的引用(例如,通过 targetWindow=window.opener
),然后使用 targetWindow.postMessage()
在其上派发 MessageEvent
。接收窗口随后可根据需要自行处理此事件。传递给 window.postMessage()
的参数通过事件对象暴露给接收窗口。
3.8.1 postMessage API 与 onmessage API
3.8.1.1 postMessage API
targetWindow.postMessage(message, targetOrigin, [transfer])
有三个参数,transfer
可选。 17
-
mesaage
就是要发送到目标窗口的消息。 -
targetOrigin
就是指定目标窗口的来源,必须与消息发送目标相一致。如果接收方窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin
提供的值,那么消息就不会被发送。值可以是字符串“*”
或url
。“*”
表示任何目标窗口都可接收。 -
transfer
是可选项,数组内的对象是实现Transferable
接口的对象。它和message
一样会被传递给目标页面,这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
3.8.1.1 onmessage API
window.onmessage = function(e){ }
参数 e
为 message
实例,里面包含了 data
、origin
、source
等属性,data
是发送方发送的 message
, origin
是发送方所属的域,source
是发送方的 window
对象的引用。
3.8.2 使用 postMessage 实现跨域通信
A页面
// A页面链接 http://localhost:8000/postMessage/A.html
B页面
// B页面链接 http://localhost:5000/postMessage/B.html
B页面请求同源接口获取数据
返回数据
4 总结
跨域方式 | 跨域分类 | 静态资源服务器配合 | 数据服务器配合 |
---|---|---|---|
jsonp | JSOP | 否 | 是 |
cors | CORS | 否 | 是 |
node request | 代理 | 是 | 否 |
http proxy | 代理 | 是 | 否 |
nginx | 代理 | 是 | 否 |
window.name | iframe | 否 | 是(页面) |
document.domain | iframe | 否 | 是(页面) |
postMessage | iframe | 否 | 是(页面) |
5 主要参考资料
[1] 什么是跨域?跨域解决方法
[2] 浏览器为什么要设计同源策略?
[3] AJAX跨域访问被禁止的原因
[4] 对于浏览器的同源策略你是怎样理解的呢?
[5] JSONP
[6] axios
[7] cors实现请求跨域
[8] CORS on ExpressJS
[9 Request - Simplified HTTP client
[10] webpack配置proxy反向代理的原理是什么?
[11] webpack devServer.proxy
[12] Vue devServer.proxy
[13] React Proxying API Requests in Development
[14] nginx 之 proxy_pass详解
[15] JS跨域--window.name
[16] Document.domain
[17] 利用window.postMessage()实现跨域消息传递