本次分享由趣头条cpc商业化技术部(周志祥-混元霹雳手)进行分享!欢迎大家投递简历加入趣头条,邮箱地址为qianjiongli@qutoutiao.net 期待您的加入。
你了解跨域吗?
了解(continue) 不了解 (end)
为何会产生跨域?
跨域问题来源于浏览器同源策略的限制问题导致的。
浏览器为何要设置同源策略?
正是因为浏览器要出于安全考虑。如果缺少了同源策略,浏览器很容易受到XSS和CSRF等攻击。(XSS
与CSRF
可以单独成为一个额外的知识点) 此时会导致一个域名下网页的操作就可以直接拿到另一个非同域名下网页的任何信息,或者一个网页可以随意请求到不同域名服务器下的接口数据。
什么是同源策略?
同源策略是一种约定,这是浏览器核心的安全功能点之一。所谓的同源策略指的是【协议 + 域名 + 端口】三者相同,如果两个相同的域名指向同一个ip
地址,也是非同源的情况。同时地址印射对应的ip
两者也是非同源情况。

同源策略会存在那些限制?
DOM节点
对于DOM
节点只能操作当前域名下网页打开的DOM
节点内容。
存储信息
对于cookie
、sessionStorage
、localStorage
、 indexedDB
等存储信息也不能非同源获取
ajax请求
对于ajax网络请求时,请求处于非同域的情况下会被浏览器自动拦截报错。
举几个造成跨域的场景的例子?
前面说过当协议、域名、端口号中任意一个不相同时,都是跨域。同样包括(一级域名与二级域名的不同) 互相请求资源的情况下是一种跨域状态。
跨域的地址场景图

通过什么方式可以解决跨域?
可以通过JSONP的原理
首先明白对于浏览器加载资源时可以通过:
- img
- script
- link
以上几个标签是允许跨域加载资源的。意思就是在www.baidu.com
域名下静态html
文件中的script
标签可以加载wwww.google.com
服务器下的脚本资源等。
通过以上标签可以加载跨域资源的理解,那我们可以通过包装手段从其它域获取到期望的数据。
讲讲JSONP的实现原理?
之前已经有了原理的思路的铺垫。那就利用script
标签这一允许跨域加资源的特性包装数据进行讲解。
实现流程
// index.html
// jsonp的实现模拟
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
let script = document.createElement('script')
window[callback] = function(data) {
resolve(data)
document.body.removeChild(script)
}
params = { ...params, callback }
let arrs = []
for (let key in params) {
arrs.push(`${key}=${params[key]}`)
}
script.src = `${url}?${arrs.join('&')}`
document.body.appendChild(script)
})
}
// 调用方式
jsonp({
url: 'http://localhost:3000/getUser',
params: { name: 'peter' },
callback: 'user'
}).then(data => {
console.log(data)
})
通过以上代码实现了一个基本的JSONP
的调用执行代码。
-
声明一个
JSONP
的模拟函数, 传入的参数分别为请求地址、请求参数、前后端约定的包装函数名、 内部通过返回promise
机制来优雅的解决数据返回的获取方式。 -
通过
script
不存在跨域请求资源的机制创建一个script
临时标签。把向后台请求的地址和参数组合成query
参数的形式。 请求地址:http://localhost:3000/getUser?name=peter&callback=user
关健点是把包装的函数名(key
作为callback
, value
作为user
) 包装函数名是前后端一个约定。
- 最后组装后的
script
标签插入到document
文档中,此时浏览器就会自动向标地址发起请求。
后台返回的结果原理
// app.js 用express脚手架模拟的配合前台callback封装的返回结果
app.get('/getUser', function(req, res, next) {
let { name, callback } = req.query
console.log(name) // peter
console.log(callback) // user
res.send(`${callback}({
code: 0,
msg: '请求成功',
data: {
id: 1234
}
})`)
});
后台会通过query
参数进行解析。如果此时返回的结果是一个对象,对象中存在msg
消息,请求状态码code
,数据信息data
。
可能你会疑问为什么返回的结果的值是放在一个user执行函数中。这就是JSONP
的核心原理。回头再看看这段没有解释的代码段:
window[callback] = function(data) {
resolve(data)
document.body.removeChild(script)
}
当执行自己封装的jsonp
的方法的时候在全局定义一个函数。此函数名则是前端与后端约定的函数封装名。当后台返回结果时会执行约定好的全局函数。就是执行上方代码段, 数据参数会通过resolve
执行返回。最后删除对应的请求script
标签。
JSONP和AJAX对比,区别点在那里?
相同点:
JSONP
与ajax
两者相同点都是客户端向服务端发起请求。
不同点:
JSONP
属于利用script
标签进行了非同源策略请求,而ajax
是同源策略请求。
JSONP优缺点
优点:
JSONP
的优点是兼容性很好。因为利用的是script
标签可以非同源请求机制。这是每个浏览器基础特性。
缺点:
只支持query
参数的这种get
请求方式,交互方式存在局限性。也容易受到xss
的攻击。
如果后台不支持JSONP的封装方式怎么办?
可以通过CORS网络通信技术。(全称Cross-Orgin Resource Sharing),对于CORS
同样也需要前后端进行一个配合。但是关健点在于后台的配置。可能你会认为。即然是后台进行配置,为什么前台也需要充分的了解。因为无论在生产还是开发的模式下, 跨域首先对前端的影响面是最大的, 只有充分的了解才能向后台去表达后台才能准确的设置和进行配合。
简单的跨域请求需要建议后台进行什么设置?
前台模拟设置
先本地创建一个index.html
写入请求脚本。通过http-server -p 4000
启动在本地4000
端口下。
// index.html
let url = 'http://localhost:3000/getUser';
let xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.send();
后台模拟设置
通过express
框架设置请求地址,服务启动在本地3000
端口下。
// app.js
let express = require('express')
let app = express()
app.get('/getUser', function(req, res) {
res.send({
code: 0,
msg: '请求成功',
data: {
id: 1234
}
})
})
app.listen(3000)
浏览器返回结果
访问http://127.0.0.1:4000/index.html
可以通过Network
控制台可以看到浏览器端向后台http://localhost:3000/getUser
服务接口地址发出请求。
如果Origin
指定的源,不在许可范围内,服务器会返回一个正常的HTTP
回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin
字段,就知道出错了,从而抛出一个错误,被XMLHttpRequest
的onerror
回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP
回应的状态码有可能是200
。虽然返回的 Status Code
状态码是 200 OK
,但是response
响应头里并没有返回期望的值。同样在console
控制台可以发现:
Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
CORS
策略阻止了从http://127.0.0.1:4000
访问http://localhost:3000/getuser
处的XMLHttpRequest
:请求的资源上没有'Access- control - allow-origin'
头。
这就是一个最简单的CORS
的安全策略,从报错可以很明显的明白你需要告诉后台需要设置'Access- control-allow-origin'
头。
后台解决方案
// app.js中添加
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
// res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
next()
})
在接收到请求时做一层中间件的过滤, 以下两者方式皆可。
- 返回时设置响应头的
Access-Control-Allow-Origin
为*
(代表所有域名向当前服务请求都允许跨域访问) - 返回时设置响应头的
Access-Control-Allow-Origin
为指定的域名。其它域名都不允许进行一个跨域访问
设置Access-Control-Allow-Origin头就可以解决了所有的跨域问题了麻?
Access-Control-Allow-Origin
头的设置仅仅只能解决简单的跨域请求
简单的跨域请求条件:
条件1: 只能允许以下的请求方法
- GET
- HEAD
- POST
条件2: Content-Type
允许条件
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
条件3: 不能超过http
的头信息以下字段
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
那其它请求方式如何解决?属于什么类型的跨域请求?
其它的请求方式被称之为复杂的跨域请求。一旦不符合简单跨域请求策略的时候那就是复杂的跨域请求:
复杂的跨域请求解释:
- 除了简单的跨域请求的方法。比如
PUT
、DELETE
- 除了简单的跨域请求的
Content-type
类型。比如application/json
- 自定义的
header
头 - 不同域名下的
cookie
传输
尝试解决复杂跨域的几种情况
1.put、delete等请求方法造成复杂请求
// 修改请求方法
- xhr.open('get', url, true);
+ xhr.open('put', url, true);
// 修改后台接收请求方法
app.put('/getUser') // 省略... 对于后台只是把get请求换成put接收请求
在浏览器的netWork
中发现并没有发送put
请求,在General
中的Request Method
发现发送了一个OPIONS
的预检请求(关于预检后续会在解决跨域问题中通过关闭浏览器策略中专门介绍相关详细知识点)
同时浏览器中会被发出报错信息:
Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
解决方案:
// 在app.use中添加新的设置头
// res.setHeader('Access-Control-Allow-Methods', '*')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
以上设置了接收允许那些请求方法:
- 设置
*
, 表示所有请求方法都允许。 - 设置对应的请求方法以逗号分隔。
2.content-type造成复杂请求
+ xhr.setRequestHeader('content-type', 'application/json');
在之前谈论简单跨域请求条件二, 关于content-type
类型对于简单的跨域请求只支持三种。设置其它的则会产生复杂的跨域请求。当设置content-type: application/json
的情况下,同样的浏览器会发出报错信息:
Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
从报错提示可以看出后台需要对复杂跨域请求content-type
进行一个额外的设置:
// 在app.use中添加新的设置头
+ res.setHeader('Access-Control-Allow-Headers', 'content-type')
3.自定义头造成复杂请求
+ xhr.setRequestHeader('X-Customer-Header', 'value');
在之前谈论简单跨域请求条件三中, 除了以上几种http
请求头之后,都属于自定义头。在请求带入时会造成复杂的跨域请求, 同样的浏览器会发出报错信息。
Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field x-customer-header is not allowed by Access-Control-Allow-Headers in preflight response.
同样的原理对于前台设置的自定义头后,后台在接收的时候同样也要进行允许设置接收前台自定义传输出来的自定义头。
res.setHeader('Access-Control-Allow-Headers', 'content-type, X-Customer-Header')
// res.setHeader('Access-Control-Allow-Headers', '*')
在Access-Control-Allow-Headers
设置的时候,可以用逗号分隔,进行多个自定义头的设定。同时也可以传入*
,允许所任何自定义头。
谈谈CROS中的cookie?
绝对同域的情况下
在绝对同域的情况下。前台向后台请求的接口或者请求文件的时候,会自动把cookie
带入请求头中。
在非同域的情况下
在非同域的情况下。需要使用CORS
的策略进行传输。默认情况下,cookie
并不会带入请求头中,需要对xhr
设置请求凭证。
xhr.withCredentials = true
简单的跨域请求与cookie
如果此时是简单的跨域请求, 设置withCredentials = true
的情况下。请求头中会带入cookie
信息, 后台接收请求并且会发送到前台, 此时浏览器端从response
中可以看到数据已经返回,但是并不能获取的后台返回的数据, 因为此时会被xhr
的错误进行捕获,浏览器控制台会出现以下提示:
Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://localhost:4000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
复杂的跨域请求
如果此时是复杂的跨域请求,设置withCredentials = true
的情况下。此时会发送一个OPTIONS
请求。浏览器发出的错误信息仍然是与简单的跨域请求报错一致。
解决方案
此时前台发送cookie
凭证, 同样的后台一样需要同意接收凭证。
res.setHeader('Access-Control-Allow-Credentials', true)
反向原理:
如果后台同意接收凭证。而前台没有设置发送凭证的情况下。就算后台发送到前台的响应头中设置了cookie
信息(set-cookie头),无论是简单的跨域请求还是复杂的跨域请求都会导致cookie
塞入无效,可以查看appliation/cookie
中, 不会有后台写入的cookie
信息。
保持同源策略
为了安全问题。cookie
本质上还是保持了同源策略的模式。在前后台都设置了发送/接收凭证之后, 对于反回的origin
头的设置res.setHeader('Access-Control-Allow-Origin', '*')
不能为*
, 需要设置成指定请求的来源 res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
。
合法组合与非法组合。
当设置Credentials
的时候,后台需要知道Access-Control-Allow
的合法与非法组合性。
一旦Access-Control-Allow-Credentials
设置为true
的时候, 此时以下几个不能设置为*
, 需要进行指定, 否则以下三者一率视为无效设置。
- Access-Control-Allow-Headers
- Access-Control-Allow-Origin
- Access-Control-Allow-Methods
CORS情况下如何在xhr中拿到响应头中的信息?
可以通过xhr.getResponseHeader
方法进行获取。但是此方法只能拿到6个基本字段:Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
。
在后台响应的时候可以响应头中塞入一些自定义的头和值。
res.setHeader('name', 'peter')
在响应体的报文中可以看到:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type, X-Customer-Header
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Origin: http://localhost:4000
Connection: keep-alive
Content-Length: 50
Content-Type: application/json; charset=utf-8
Date: Sun, 17 Feb 2019 08:18:08 GMT
ETag: W/"32-oUKytSTXnBL0hnySFj9PpHgmBQk"
name: peter // 重点在这里
X-Powered-By: Express
通过报文可以发现返回的很多之前后台设置的信息和这里最关健的name
头信息。但是通过以下方法测试之后结论:
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
console.log(xhr.getResponseHeader('Content-Type'))
console.log(xhr.getResponseHeader('name'))
}
}
}
在xhr
返回成功之后。分别获取两个头信息。
-
Content-Type
则会返回application/json; charset=utf-8
-
name
则会提示报错信息,并且返回null
空值。
Refused to get unsafe header "name" // 拒绝获取不安全的头信息“name”
可以明确的知识,除了之前提到的以上六种头信息可以进行获取之外,其余的一律都需要在后台进行允许那响应些头访问的设置。
res.setHeader('Access-Control-Expose-Headers', 'name')
此时浏览器中报错信息不会存在,同时也能打印出name
在响应头中的值。注意 如果设置的值为 *
则无效。需要对指定字段头进行设置。
复杂的跨域请求会造成每次请求都发送一个OPTIONS请求,如何解决?
通过以上的所有对复杂的跨域请求的分析清楚的认识到,那些请求方式会造成发送预检,一句话概括,**Access-Control-Max-Age 这个响应首部表示 preflight request (预检请求)的返回结果(即 Access-Control-Allow-Methods 和Access-Control-Allow-Headers 提供的信息) 可以被缓存多久。**这样对network
中的请求观察和请求性能来说都不友好。如果做到友好又安全的机制。
对预检进行一个时间请求有效期
res.setHeader('Access-Control-Max-Age', 600)
对预检请求设置10
分钟的过期时间(时间可以根据项目情况进行自定义)。但是对于每个浏览器的缓存时间机制都不一样。在本地调试的时候,有时候你会发现设置了预检的过期时间并不生效。注意一下可能开启了浏览器的Disable cache导致了此原因
在前后端联调时,不通过后端设置,如何解决跨域问题?
关闭浏览器跨域策略。
通过之前分析整个跨域模式是由前台浏览器的所作所为造成的。为了安全,浏览器对跨域请求做了一系列的验证。那是否可以想想, 通过手动关闭浏览器跨域策略是不是可以解决根本性的问题。
Mac 创建一个chrome.sh文件
#!/bin/bash
#!/bin/sh
open -a "Google Chrome" --args --disable-web-security --user-data-dir
exit 0
通过终端运行:
sh 加上chrome.sh文件地址
注意:
在运行终端命令的时候,先检查是否已经启动过chrome
,如果启动过需要手动关闭整个chrome
的进程。
成功结果:

输入URL
地址之后。所有的跨域问题会一并解决。
原理
虽然浏览器的跨域策略已经被关闭了。不存在任何浏览发送的跨域行为, 其内部原理正是因为浏览器会对简单的跨域请求做了拦截和复杂的跨域请求做了发送预检。
简单的跨域请求通过什么进行拦截?
在理解简单的跨域请求时先需要理解两个请求头的字段。
request请求头中的Origin
请求首部字段 Origin
指示了请求来自于哪个站点。该字段仅指示服务器名称,并不包含任何路径信息。该首部用于 CORS
请求。
通俗的说就是告诉服务器此时是从那个域名地址发送来的。只有在CORS
的情况下Origin
才会在请求头中出现。
request请求头中的HOST
Host
请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。
如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个HTTP
的URL
会自动使用80
端口)。
HTTP/1.1
的所有请求报文中必须包含一个Host头字段。如果一个 HTTP/1.1
请求缺少Host
头字段或者设置了超过一个的 Host
头字段,一个400(Bad Request
)状态码会被返回。
通俗的说就是浏览器向服务端发送请求时, 所请求的服务器的域名地址。
响应头中的Access-Control-Allow-Origin
响应头指定了该响应的资源是否被允许与前台请求头给定的origin
共享。
结论
所以跨域请求返回浏览器之后。虽然数据会返回但是。浏览器会比对请求头中的Origin
与响应头中的Access-Control-Allow-Origin
是否是共享匹配,如果不匹配。浏览器的xhr
会捕获错误并且在浏览器端控制台抛出错误。并不能拿到期望的数据。
复杂的请求浏览器是如何检测跨域的?
对于复杂的请求跨域, 浏览器一旦检测此发送的请求头存在属于复杂的跨域请求时, 首先会发送一个预请求, 请求头中包函着以下重要的内容:
Access-Control-Request-Headers
(如果有自定义头或者content-type
类形不属于简单请求的类型的情况下才会出来)Access-Control-Request-Method
(除了简单的请求方法才会出现)
并且在发送预检请求时并不会把请求数据和cookie
信息带入请求信息中。
什么是预检请求?
在CORS
中会使用 OPTIONS
方法发起一个预检请求(preflight request), 以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。
当浏览器请求头中发出request-Header
或者request-Method
时。此时服务端需要同意这两个请求头中对应的信息通过允许。需要在响应返回的时候对响应头做出响应处理。需要对Access-Control-Allow-Methods
和Access-Control-Allow-Headers
设置。
原理图:

附带Credentials
(身份凭证的)请求属于简单的跨域请求还是复杂的跨域请求?
关于Credentials
在CORS
中原理性已经讲的很明白了。但是这里想讲的就是在xhr
中Credentials
设置为true
时。此时只是简单的跨域请求,不会发送预检(OPTIONS
)请求, 如果此时是复杂的跨域请求。会发送预检(OPTIONS
)请求。
所以Credentials
是否会发送预检,主要需要通过其它请求头的判定来决定是否需要发送预检。
原理图:

总结:
只有当request
请求头与response
返回头一一对应上了。互相允许通过共享策略。对于简单的跨域请求则不会被捕获错误.对于复杂的跨域请求则会发送真正的请求。同时会把cookie
等传输数据带入请求体中。所以说关闭浏览器跨域策略就是关闭了浏览器对响应Origin
头匹配时不再捕获,同时也会关闭对应的OPTIONS
预检请求。直接发送给对应的后台服务器。所以说本质上虽然存在跨域,但是服务端永远是返回数据。一切的错误或者没有发送真正的请求都是浏览器的安全机制所为。
如何通过代理劫持机制解决跨域?
前面我们已经知道浏览器向服务器请求是存在跨域问题,但是服务器向服务器发送请求是不存在跨域问题。通过MS(middle server)
进行请求劫持之后,通过服务端向服务端发送请求,再二次返回给浏览器端。
示意图:

在各大框架中都通过脚手架启动node
服务承载着项目。例如vue-cli
中就利用了http-proxy-middle
进行一个请求的代理拦截,向目标服务器发送请求来解决跨域问题。
// 通过express启用3000端口
// index.html
<script>
let url = '/api/getUser';
let xhr = new XMLHttpRequest();
xhr.open('post', url, true);
xhr.setRequestHeader('content-type', 'application/json');
xhr.setRequestHeader('X-Customer-Header', 'value');
xhr.send();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
console.log(1)
console.log(xhr.response)
}
}
}
</script>
const proxyOption = {
target: 'http://localhost:4000',
pathRewrite: {
'^/api/' : '/' // 重写请求,api/解析为/
},
changeOrigin:true
};
app.use('/api', proxy(proxyOption))
// 后台服务启动4000端口
app.post('/getUser', (req, res, next) => {
res.send({
code: 1
})
})
当3000
端口的静态文件发送ajax
请求的时候,本身就是在一个域名下,不会造成任何跨域问题,同时会被app.use('/api/')
捕获拦截,同时改写url
地址向服务端4000
端进行请求发送数据。此时就是server
端与server
端的请求通信。当4000
端口的server
接收到请求之后把数据返回给3000
端口的server
端,同时再返回给请求的ajax
。
用node原生API如何实现?
app.use('/api', (req, res) => {
const reqHttp = http.request({
host: '127.0.0.1',
path: '/getUser',
port: '4000',
method: req.method,
headers: req.headers
}, (resHttp) => {
let body = ''
resHttp.on('data', (chunk) => {
console.log(chunk.toString())
body += chunk
});
resHttp.on('end', () => {
res.end(body)
});
})
reqHttp.end()
});
以上代码本质上是模拟了代理劫持的方式,同时当拦截到url
开头以/api
起始的请求之后,通过node
原生http
模块的request
方法向对应的后台发送请求,同时把浏览器请求过来的一些请求体,请求头等数据一并传给server
端。通过http
模块监听的结束方法最后把数据再返回到client
浏览器端。这样形成了二次转方式解决跨域问题。整体就是利用了服务端向服务发送请求不会有跨域策略的限制,就是所谓的同源策略。因为浏览器会做options
等预检的检测,而服务端并不会。