CORS和web跨域

CORS

w3c-CORS

CORS(Cross-Origin Resource Sharing),即跨域资源共享。它是一种机制,使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin (domain) 上的Web应用被准许访问来自不同源服务器上的指定的资源。

  • ,指的是一个站点,由 protocal、host 和 port 三部分组成,其中 host 可以是域名,也可以是 ip ;port 如果没有指明,则是使用 protocal 的默认端口
  • 资源,是指一个 URL 对应的内容,可以是一张图片、一种字体、一段 HTML 代码、一份 JSON 数据等等任何形式的任何内容
  • 同源策略,指的是为了防止 XSS,浏览器、客户端应该仅请求与当前页面来自同一个域的资源,请求其他域的资源需要通过验证。

概述

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

CORS请求失败会产生错误,但是为了安全,在JavaScript代码层面是无法获知到底具体是哪里出了问题。你只能查看浏览器的控制台以得知具体是哪里出现了错误。

跨域条件

当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

如以下各组域名都是不同源,相互请求数据就会发生跨域:

1
2
3
1.http://www.michealwayne.cn与https://www.michealwayne.cn
2.http://www.michealwayne.cn与http://blog.michealwayne.cn
3.http://www.michealwayne.cn与http://www.michealwayne.cn:8888

并且跨域只存在于浏览器端,不存在于安卓/ios/Node.js/python/ java等其它环境;跨域请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。

这三个源分别由于域名、协议和端口号不一致,导致会受到同源策略的限制。

同源策略(限制)

  • 不能向工作在不同源的的服务请求数据(client to server)
  • 无法获取不同源的document/cookie等BOM和DOM,可以说任何有关另外一个源的信息都无法得到 (client to client)

为何要有同源限制?为了安全考虑,在此不详细描述。

同源策略提供了安全的同时也造成了不方便,因为有时候我们需要跨域请求,如获取第三方提供的服务信息,由于第三方的源和本网站的源不一样,所以这个时候就受到跨域的限制。

跨域最常用的方法,应当属CORS。

CORS使用场景

一般如下:

  • 由 XMLHttpRequest 或 Fetch 发起的跨域 HTTP 请求。
  • Web 字体 (CSS 中通过 @font-face 使用跨域字体资源), 因此,网站就可以发布 TrueType 字体资源,并只允许已授权网站进行跨站调用。
  • WebGL 贴图使用 drawImage 将 Images/video 画面绘制到 canvas

请求过程

假如站点 http://foo.example 的网页应用想要访问 http://bar.other 的资源。http://foo.example 的网页中可能包含类似于下面的 JavaScript 代码:

1
2
3
4
5
6
7
8
9
10
const invocation = new XMLHttpRequest();
const url = 'http://bar.other/resources/public-data/';

function callOtherDomain() {
if (invocation) {
invocation.open('GET', url, true);
invocation.onreadystatechange = handler;
invocation.send();
}
}

客户端和服务器之间使用 CORS 首部字段来处理跨域权限:

p-1.png

分别检视请求报文和响应报文:

1
2
3
4
5
6
7
8
9
10
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example

请求首部字段 Origin 表明该请求来源于 http://foo.exmaple。

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[XML Data]

响应中携带了响应首部字段 Access-Control-Allow-Origin(第 16 行)。使用 Origin 和 Access-Control-Allow-Origin 就能完成最简单的访问控制。本例中,服务端返回的 Access-Control-Allow-Origin: * 表明,该资源可以被任意外域访问。如果服务端仅允许来自 http://foo.example 的访问,该首部字段的内容如下:

1
Access-Control-Allow-Origin: http://foo.example

CORS把请求分为两种,一种是简单请求,另一种是需要触发预检请求

简单请求

不会触发CORS预检请求的请求。满足条件:

  • 首先请求方法为GET/HEAD/POST;
  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:Accept、Accept-Language、Content-Language、Content-Type、DPR、Downlink、Save-Data、Viewport-Width、Width
  • Content-Type 的值属于下列之一: application/x-www-form-urlencodedmultipart/form-datatext/plain
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
  • 请求中没有使用 ReadableStream 对象。

*预检请求

需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

预检请求的触发条件

  • 以下任意HTTP方法:PUT、DELETE、CONNECT、OPTIONS、TRACE、PATCH
  • 人为设置了对 CORS 安全的首部字段集合(Accept、Accept-Language、Content-Language、Content-Type、DPR、Downlink、Save-Data、Viewport-Width、Width)之外的其他首部字段。
  • Content-Type 的值不属于下列之一: application/x-www-form-urlencodedmultipart/form-datatext/plain
  • 请求中的XMLHttpRequestUpload 对象注册了任意多个事件监听器。
  • 请求中使用了ReadableStream对象。

p-2.png

OPTIONS 是 HTTP/1.1 协议中定义的方法,用以从服务器获取更多信息。该方法不会对服务器资源产生影响。 预检请求中同时携带了下面两个首部字段:

1
2
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

首部字段 Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法。首部字段 Access-Control-Request-Headers 告知服务器,实际请求将携带两个自定义请求首部字段:X-PINGOTHER 与 Content-Type。服务器据此决定,该实际请求是否被允许。

预检请求完成之后,才会发送实际请求。

预检请求与重定向

大多数浏览器不支持针对于预检请求的重定向。如果一个预检请求发生了重定向,浏览器将报告错误。

1
The request was redirected to 'https://example.com/foo', which is disallowed for cross-origin requests that require preflight

1
Request requires preflight, which is disallowed to follow cross-origin redirect

在浏览器的实现跟上规范之前,有两种方式规避上述报错行为:

  • 在服务端去掉对预检请求的重定向;
  • 将实际请求变成一个简单请求。

如果上面两种方式难以做到,我们仍有其他办法:

  • 发出一个简单请求(使用 Response.url 或 XHR.responseURL)以判断真正的预检请求会返回什么地址。
  • 发出另一个请求(真正的请求),使用在上一步通过Response.url 或 XMLHttpRequest.responseURL获得的URL。

不过,如果请求是由于存在 Authorization 字段而引发了预检请求,则这一方法将无法使用。这种情况只能由服务端进行更改。

HTTP 请求首部字段

Origin

Origin 首部字段表明预检请求或实际请求的源站。语法:

1
Origin: <origin>

origin 参数的值为源站 URI。它不包含任何路径信息,只是服务器名称

Access-Control-Request-Method

Access-Control-Request-Method 首部字段用于预检请求。其作用是,将实际请求所使用的 HTTP 方法告诉服务器。

1
Access-Control-Request-Method: <method>

Access-Control-Request-Headers

Access-Control-Request-Headers 首部字段用于预检请求。其作用是,将实际请求所携带的首部字段告诉服务器。语法:

1
Access-Control-Request-Headers: <field-name>[, <field-name>]*

HTTP 响应首部字段

Access-Control-Allow-Origin

语法:

1
Access-Control-Allow-Origin: <origin> | *

其中,origin 参数的值指定了允许访问该资源的外域 URI。对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符,表示允许来自所有域的请求。

Access-Control-Expose-Headers

Access-Control-Expose-Headers 头让服务器把允许浏览器访问的头放入白名单。在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的响应头,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他头,则需要服务器设置本响应头。

如:

1
Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header

这样浏览器就能够通过getResponseHeader访问X-My-Custom-Header和 X-Another-Custom-Header 响应头了。

注意,如果要匹配子域名通常会比较麻烦,比如适用所有michealwayne.cn,使用 nginx 的话需要进行判断,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ...

set $cors_origin "";
if ($http_origin ~ .*.michealwayne.cn) {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';

if ($request_method = OPTIONS) {
return 204;
}

# ...

Access-Control-Max-Age

Access-Control-Max-Age 头指定了preflight请求的结果能够被缓存多久。
语法:

1
Access-Control-Max-Age: <delta-seconds>

delta-seconds 参数表示preflight请求的结果在多少秒内有效。

Access-Control-Allow-Credentials

Access-Control-Allow-Credentials 头指定了当浏览器的credentials设置为true时是否允许浏览器读取response的内容。当用在对preflight预检测请求的响应中时,它指定了实际的请求是否可以使用credentials。请注意:简单 GET 请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。

1
Access-Control-Allow-Credentials: true

Access-Control-Allow-Methods

Access-Control-Allow-Methods 首部字段用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法。语法:

1
Access-Control-Allow-Methods: <method>[, <method>]*

Access-Control-Allow-Headers

Access-Control-Allow-Headers 首部字段用于预检请求的响应。其指明了实际请求中允许携带的首部字段。

1
Access-Control-Allow-Headers: <field-name>[, <field-name>]*

CORS实现

nginx配置


1
2
3
4
5
6
7
8
9
10
11
12
13
14
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
}

nodejs(express)

1
2
3
4
5
6
7
8
9
10
11
var express = require('express');
var app = express();

app.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "X-Requested-With");
res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
res.header("X-Powered-By",' 3.2.1')
res.header("Content-Type", "application/json;charset=utf-8");
next();
});

兼容

compatibility.png

在不支持CORS的浏览器,如IE8,我们可以使用XDomainRequest

XDomainRequest是在IE8和IE9上的HTTP access control (CORS) 的实现,在IE10中被 包含CORS的XMLHttpRequest 取代了,如果你的开发目标是IE10或IE的后续版本,或想要支待其他的浏览器,你需要使用标准的HTTP access control。

不过需要注意的是

  • XDomainRequest 只支持 GET 和 POST mehtod
  • XDomainRequest 不支持带 cookie
  • XDomainRequest 不能设置 responseType, 通信双方需要约定数据格式
  • XDomainRequest 的响应没有 response status code

web跨域

跨域分为两种,一种是跨域请求,另一种访问跨域的页面。其中跨域请求可以通过CORS/JSONP等方法进行访问,跨域的页面主要通过postMesssage的方式。由于跨域请求不但能发出去还能带上cookie,所以要规避跨站请求伪造攻击的风险。

解决方案

CORS,见上文

JSONP

也是很常用的跨域解决方案,但有局限性也有安全风险。
JSONP是利用了<script>标签能够跨域的特性,通过动态js注入的方式实现跨域请求数据。


1
2
3
const script = document.createElement("script");
script.src = "https://abc/search";
document.body.insertBefore(script, document.body.firstChild);

在页面中,返回的JSON作为参数传入回调函数中,我们通过回调函数来来操作数据。

1
2
3
function handleResponse(response){
// 对response数据进行操作代码
}

jsonp的几个缺点:

  • 只支持GET
  • script标签带来的安全问题。如果其他域不安全,很可能会在响应中夹带一些恶意代码,而此时除了完全放弃 JSONP 调用之外,没有办法追究。因此在使用不是你自己运维的 Web 服务时,一定得保证它安全可靠。
  • JSONP 请求通过<script>onerror捕获到的失败信息没有状态码等详细信息。

IE的XDomainRequest,见上文

document.domain

通过修改document.domain可以实现二级域名相同的跨域问题,如www.michealwayne.cn中请求blog.michealwayne.cn,可修改为

1
document.domain = 'michealwayne.cn';

注意,在2022年,Chrome 101版本起,document.domain变为可读属性,因此这个方案开始不建议使用。可见https://developer.chrome.com/blog/immutable-document-domain/

postMessage

适用于用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接受消息。

1
2
3
4
5
6
7
8
9
window.parent.postMessage('message', 'https://test.cn');
var mc = new MessageChannel();

mc.addEventListener('message', event => {
let origin = event.origin || event.originalEvent.origin;
if (origin === 'https://test.cn') {
// ...
}
})

借助端侧能力

如果页面仅在自家应用程序中投放,可以借助应用程序能力代发跨域请求,如jsbridge。


相关链接