【HTTP】结合nodejs以及浏览器报错,了解跨域解决方案——CORS

914 阅读11分钟

写在前面的话:本文结合nodejs,通过列举多个情况下的前后端实例,以及浏览器的报错信息来细化CORS,希望阅读完本文后能给大家一些新的启发。另,笔者水平有限,如有错误还望指出,谢谢。

1. 一些概念

1.1 什么是“同源”?

即是“浏览器的同源策略”,定义如下:

如果两个URL的scheme(协议)、host(主机)和port(端口)都相同的话,则这两个URL是同源。否则就是非同源。

1.2 什么是“跨域”?

“跨域”即是“跨源”,当一个Web应用发起一个与自身所在源不同的HTTP请求时,它发起的即跨源HTTP请求。

跨域行为受到非同源限制,具体如下:

  • Cookie、LocalStorage和IndexDB无法读取
  • DOM无法获得
  • AJAX请求不能发送(可以发送,但浏览器会拒绝接受响应)

1.3 什么是CORS?

即是“跨源资源共享(Cross-Origin Resource Sharing)”,是一种机制,该机制使用附加的HTTP头来告诉浏览器,准许运行在一个源上的Web应用访问位于另一不同源选定的资源。

简单来说,CORS是解决跨域的方案之一,需要浏览器和服务器分别通过请求头和响应头共同支持。
顺便说几个解决跨域的方案:
  • JSONP
  • CORS
  • Nginx
  • postMessage
注:本文只详细探讨CORS方案。

2. CORS

2.1 分类

浏览器将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)
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。

2.2 涉及到的HTTP头

以下HTTP头决定浏览器是否阻止前端JavaScript代码获取跨域请求的响应。

1)简单请求

请求头:

Origin:由浏览器自动在头信息中添加,用来说明本次请求来自哪个源(协议+域名+端口)。

响应头:

Access-Control-Allow-Origin:必须值,指定了该响应的资源是否被允许与给定的origin共享。

取值:
  • *:通配符,表示接受任意域名的请求。
  • origin:指定一个可以访问资源的URI。

Access-Control-Allow-Credentials:可选值,值类型是一个布尔值,表示是否允许发送Cookie。对于跨域请求,该值默认为false。

Access-Control-Expose-Headers:可选值,用于指示哪些HTTP头的名称能在响应中列出。可以指定多个,用逗号隔开。

默认情况下,只有七种简单响应首部(simple response headers)可以暴露给外部:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

2)非简单请求

请求头:

Origin:(同简单请求)

Access-Control-Request-Headers:用于发起一个预请求,告知服务器正式请求会使用那些HTTP头。

Access-Control-Request-Method:用于发起一个预请求,告知服务器正式请求会使用哪一种HTTP请求方法。

响应头:

Access-Control-Allow-Headers:用在对预请求的响应中,指示实际的请求中可以使用哪些HTTP头。

Access-Control-Allow-Methods:必须值,指定对预请求的响应中,哪些HTTP方法允许访问请求的资源。

Access-Control-Allow-Credentials:(同简单请求)

Access-Control-Max-Age:指示预请求的结果能被缓存多久。

2.3 浏览器的兼容性

兼容非IE和IE10以上

3. 一些例子

以下是一些结合nodejs实现的CORS请求实例,了解后端的实现更能加深对CORS的理解。

版本信息:Chrome(版本85.0.4183.121)+nodejs(v12.18.1)+express(4.17.1)

前端地址:[http://localhost:9090/](http://localhost:9090/)
后端地址:[http://localhost:3000/](http://localhost:3000/)

3.1 简单请求

3.1.1 是否允许跨域:Access-Control-Allow-Origin

1)后端允许跨域

即是,后端设置响应头Access-Control-Allow-Origin。结果:成功获取响应数据。
前端代码:
fetch('http://localhost:3000/simple-cors-origin', {
  method: 'POST',
  body: JSON.stringify({ test: 1 }),
})
.then(res => res.text())
.then(res => console.log('res:', res))
或者
var url = 'http://localhost:3000/simple-cors-origin';
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.send();

后端代码:

app.post('/simple-cors-origin', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  // 或者
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.send('123');
});

请求头:

响应头:

或者

2)后端不允许跨域

即是,后端不设置响应头Access-Control-Allow-Origin。结果:浏览器报错。

前端代码:

同情况1(后端允许跨域)。

后端代码:

app.post('/simple-cors-origin', function(req, res){
  res.send('123');
});

fetch请求,浏览器报错信息:

Access to fetch at 'http://localhost:3000/simple-cors-origin' from origin 'http://localhost:9090' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

xhr请求,浏览器报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/simple-cors-origin' from origin 'http://localhost:9090' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

小结:

CORS请求时,响应头需要设置Access-Control-Allow-Origin来允许跨域。

3.1.2 是否允许发送Cookie:Access-Control-Allow-Credentials

1)前端设置credentials,后端设置Access-Control-Allow-Origin:origin

即是,前端设置credentials,后端设置Access-Control-Allow-Origin为具体地址、以及设置Access-Control-Allow-Credentials为true。结果:前端成功发送cookie,后端成功获取cookie。
前端代码:
fetch('http://localhost:3000/simple-cors-credentials', {
  method: 'POST',
  body: JSON.stringify({ test: 1 }),
  credentials: 'include', // 设置credentials
})
.then(res => res.text())
.then(res => console.log('res:', res))

或者

var url = 'http://localhost:3000/simple-cors-credentials';
var xhr = new XMLHttpRequest();
xhr.withCredentials = true; // 设置credentials
xhr.open('POST', url, true);xhr.send();

后端代码:

app.post('/simple-cors-credentials', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.setHeader('Access-Control-Allow-Credentials', true);
  res.send('123');
});

请求头:

2)前端设置credentials,后端设置Access-Control-Allow-Origin:*

即是,前端设置credentials,后端设置Access-Control-Allow-Origin为*、以及设置Access-Control-Allow-Credentials为true。结果:浏览器报错。

前端代码:

同情况1(前端设置credentials,后端设置Access-Control-Allow-Origin:)。

后端代码:

app.post('/simple-cors-credentials', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.send('123');
});

fetch请求,浏览器报错信息:

Access to fetch at 'http://localhost:3000/simple-cors-credentials' from origin 'http://localhost:9090' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

xhr请求,浏览器报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/simple-cors-credentials' from origin 'http://localhost:9090' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

小结:

credentials属性(xhr请求时为withCredentials)的值为include时,响应头中的Access-Control-Allow-Origin的值不能为通配符*。

3)前端设置credentials,后端不设置Access-Control-Allow-Credentials

结果:浏览器报错。

前端代码:

同情况1(前端设置credentials,后端设置Access-Control-Allow-Origin:)。

后端代码:

app.post('/simple-cors-credentials', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.send('123');
});

fetch请求,浏览器报错信息:

Access to fetch at 'http://localhost:3000/simple-cors-credentials' from origin 'http://localhost:9090' 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'.

xhr请求,浏览器报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/simple-cors-credentials' from origin 'http://localhost:9090' 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.

小结:

credentials属性(xhr请求时为withCredentials)的值为include时,响应头中的Access-Control-Allow-Credentials的值必须为true。

4)前端不设置credentials,后端设置Access-Control-Allow-Credentials

结果:前端没有发送cookie,后端没有获取cookie,浏览器没有报错。

前端代码:

fetch('http://localhost:3000/simple-cors-credentials', {
  method: 'POST',
  body: JSON.stringify({ test: 1 }),
})
.then(res => res.text())
.then(res => console.log('res:', res))

或者

var url = 'http://localhost:3000/simple-cors-credentials';
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.send();

后端代码:

app.post('/simple-cors-credentials', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.setHeader('Access-Control-Allow-Credentials', true);
  res.send('123');
});

请求头:

注:如图,请求头中没有Cookie。

5)前端不设置credentials,后端不设置Access-Control-Allow-Credentials

结果:同情况4。

前端代码:

同情况4(前端不设置credentials,后端设置Access-Control-Allow-Credentials)。

后端代码:
app.post('/simple-cors-credentials', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.send('123');
});

小结:

简单请求一般默认不会携带Cookie。

3.1.3 哪些HTTP头的名称能在响应中列出:Access-Control-Expose-Headers

1)查看默认情况下暴露给外部的7种简单响应首部的值

前端代码:

var url = 'http://localhost:3000/simple-cors-expose';
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.send();
xhr.onreadystatechange = function(){
  if (xhr.readyState === 4 && xhr.status === 200) {
    console.log('Cache-Control:', xhr.getResponseHeader('Cache-Control')); // null
    console.log('Content-Language:', xhr.getResponseHeader('Content-Language')); // null
    console.log('Content-Length:', xhr.getResponseHeader('Content-Length')); // 3
    console.log('Content-Type:', xhr.getResponseHeader('Content-Type')); // text/html; charset=utf-8
    console.log('Expires:', xhr.getResponseHeader('Expires')); // null
    console.log('Last-Modified:', xhr.getResponseHeader('Last-Modified')); // null
    console.log('Pragma:', xhr.getResponseHeader('Pragma')); //null
  }
}

后端代码:

app.post('/simple-cors-expose, function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.send('123');
});

此时的响应头:

2)前端获取其他响应头字段,Access-Control-Expose-Headers里已经指定

结果:前端成功获取指定的HTTP头字段。

前端代码:

同情况1(查看默认情况下暴露给外部的7种简单响应首部的值),另打印如下:

console.log('abc:', xhr.getResponseHeader('abc')); // 111
后端代码:
app.post('/simple-cors-expose', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.setHeader('abc', '111');
  res.setHeader('Access-Control-Expose-Headers', 'abc');
  res.send('123');
});

此时的响应头:

3)前端获取其他响应头字段,Access-Control-Expose-Headers里没有指定

结果:浏览器报错。

前端代码:

同1(查看默认情况下暴露给外部的7种简单响应首部的值),另打印如下:

console.log('abc:', xhr.getResponseHeader('abc')); // 报错
后端代码:
app.post('/simple-cors-expose', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:9090');
  res.setHeader('abc', '111');res.send('123');
});

报错信息:

Refused to get unsafe header "abc"

小结

  • 服务器根据请求头Origin的值来设置响应头Access-Control-Allow-Origin的值,从而实现跨域效果。
  • 响应头Access-Control-Allow-Origin的值可以是通配符*或者一个具体URI地址。
  • 响应头Access-Control-Allow-Credentials,表示是否允许发送Cookie。
  • 简单请求一般默认不会携带Cookie。
  • 发送Cookie,需要前端和后端同时设置相应的值,否则,即使服务器同意发送Cookie,浏览器也不会发送。

3.2 非简单请求

3.2.1 是否允许使用请求头X-Custom-Header

1)允许使用X-Custom-Header

结果:请求成功,获取响应数据。

前端代码:

fetch('http://localhost:3000/unsimple-cors', {
  method: 'POST',
  body: JSON.stringify({ test: 1 }),
  headers: {
    'X-Custom-Header': 'xxx', // 自定义请求头
  },
})
.then(res => res.text())
.then(res => console.log('res:', res))

或者

var url = 'http://localhost:3000/unsimple-cors';
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('X-Custom-Header', 'xxx'); // 自定义请求头
xhr.send();
后端代码:
app.options('/unsimple-cors', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'PUT,GET,POST');
  res.setHeader('Access-Control-Allow-Headers', 'X-Custom-Header');
  res.end();
});
app.post('/unsimple-cors', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.send('123');
});
非简单请求的CORS请求有两次请求,一次为预检请求(请求方法为option),一次为正式请求。如下:

预检请求的请求头:(浏览器自动添加Access-Control-Request-Method和Access-Control-Request-Headers字段)

正式请求的请求头:

2)不允许使用X-Custom-Header

结果:浏览器报错。

前端代码:

同情况1(允许使用X-Custom-Header)

后端代码:

app.options('/unsimple-cors', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'PUT,GET,POST');
  res.end();
});
app.post('/unsimple-cors', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.send('123');
});

fetch请求,浏览器报错信息:

Access to fetch at 'http://localhost:3000/unsimple-cors' from origin 'http://localhost:9090' has been blocked by CORS policy: Request header field x-custom-header is not allowed by Access-Control-Allow-Headers in preflight response.

xhr请求,浏览器报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/unsimple-cors' from origin 'http://localhost:9090' has been blocked by CORS policy: Request header field x-custom-header is not allowed by Access-Control-Allow-Headers in preflight response.

小结:

非简单请求时,请求头中自定义的字段(X-Custom-Header)必须被响应头中的Access-Control-Allow-Headers允许。

3.2.2 本次预检请求的有效期:Access-Control-Max-Age

1)不使用Access-Control-Max-Age

每次CORS请求都是两次请求(预检+正式)。

2)使用Access-Control-Max-Age

第一次CORS请求为两次(预检+正式),之后在有效期内为一次(正式),超过有效期后的初次请求为两次,接着又是有效期内的一次,如此循环。
后端代码:
app.options('/unsimple-cors-maxage', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'PUT,GET,POST');
  res.setHeader('Access-Control-Max-Age', 10); // 指定时间为10秒
  res.end();
});
app.post('/unsimple-cors-maxage', function(req, res){
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.send('123');
});

小结

  • 非简单请求的CORS请求有两次请求,一次为预检请求(请求方法为option),一次为正式请求。 
  • 预检请求时,浏览器在请求头中自动添加Access-Control-Request-Method和Access-Control-Request-Headers字段 。
  • 非简单请求时,请求头中自定义的字段必须被响应头中的Access-Control-Allow-Headers允许。

4. 参考资料

[浏览器同源政策及其规避方法](http://www.ruanyifeng.com/blog/2016/04/same-origin-policy.html)
[跨域资源共享CORS详解](http://www.ruanyifeng.com/blog/2016/04/cors.html)
[跨源资源共享(CORS)](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS)