发布于 

跨域和OPTIONS这对欢喜冤家


简介

相信做过前端开发的同事,包括做小程序或者小游戏的码友们应该都看过类似下面的错误,这个错误是由于 JavaScript 代码向服务器发送了 HTTP 请求引起的。

1
2
3
Access to XMLHttpRequest at 'http://www.xxx.com/yyy' from origin 'null' 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.

如果是第一次遇到,你肯定会觉得很好奇,忍不住会去一探究竟~

还有些同事会遇到另一个神奇的错误,即发送GET或者POST请求之前,居然先给服务器发送了一个 OPTIONS 请求,让人不可思议的是这个 OPTIONS 请求是自动发的,服务器在没有任何设置的条件下直接将这个请求夭折掉,如下返回 403 错误,也可能是其他错误。

1
OPTIONS http://www.xxx.com/yyy 403

引起这些问题的罪魁祸首就是 跨域 ,今天我跟大家一起以实际的例子来看看这个神奇的 跨域 问题。

文中使用的代码都可以在 Github 找到,大家根据需要自行采纳。

同源策略

,是指由 协议 + 域名 + 端口号 组成的一个虚拟概念。

如果两个域的 协议域名端口号 都一样,就称他们为同域,但是只要三者之中有一个不一样,就不是同域。

那么 跨域请求 简单来说,就是在一个域内请求了另一个域的资源,由于域不一致会有安全隐患如 CSRF (Cross-site request forgery)攻击。

在百度百科里面是这样定义 同源策略 的,如下:

1
2
3
同源策略(SOP,Same origin policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。

可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。

听起来,这玩意挺高大上的,简单理解 同源策略 就是一种安全策略,为了安全而生的一种限制措施。它是由 Netscape 提出的一个著名的安全策略,现在所有支持 JavaScript 的浏览器都会使用这个策略,也是必须遵守的一个策略。

那么 同源策略 中的 同源 是指 域名协议端口 三者必须相同,如果有任何一个不同就会引起跨域。

下表给出了相对 http://a.xx.com/yy/zz.html 同源检测的示例:

URL 结果 原因
http://a.xxx.com/ff/other.html 成功 域名、协议、端口(默认80)一致
http://a.xxx.com/gg/hh/another.html 成功 域名、协议、端口(默认80)一致
https://a.xxx.com/secure.html 失败 不同协议 ( HTTPS和HTTP )
http://a.xxx.com:81/dir/etc.html 失败 不同端口 ( 81和80)
http://a.wpq.com/yy/other.html 失败 不同域名 ( xxx和wpq)
http://123.21.122.12/dir 失败 域名IP不等同于域名
http://xx.xxx.com/dir2/ 失败 主域相同,子域不同

简单来说,HTML 代码运行在一个web主机上面(假设域名是 http://a.xx.com/yy/zz.html),而HTML代码中有需要请求服务器某 API 接口(http://api.user.com/name)的,那么就会造成跨域问题。

同源策略会影响:

(1) Cookie、LocalStorage 和 IndexDB 无法读取;

(2) DOM 无法获得;

(3) AJAX 请求不能正常发送,有可能还会引起 OPTIONS 请求;

OPTIONS请求

大家所熟知的HTTP请求最多的应该就是 GET 和 POST 请求,这两种请求也是软件开发中用的最多的。

GET:向特定的资源发出请求,一般对服务器来说是一个只读的请求,不会对资源进行写操作。

POST:向指定资源提交数据进行处理请求,例如提交表单或者上传文件,数据被包含在请求体(body)中,该请求可能会对服务器资源进行读写操作。

除了这两种请求外,HTTP还有其他种类的请求,如下:

PUT:向指定资源位置上传其最新内容,一般用于资源的整体更新,而下面的 PATCH 用于资源的部分更新。

DELETE:请求服务器删除所标识的资源。

HEAD:向服务器索要与 GET 请求相一致的响应,只不过响应体将不会被返回,可以在不必传输整个响应内容的情况下,就可以获取包含在响应消息头中的元信息。

TRACE:回显服务器收到的请求,主要用于测试或诊断。

OPTIONS:返回服务器针对特定资源所支持的 HTTP 请求方法。也可以利用向Web服务器发送 ‘*’ 的请求来测试服务器的功能性。该请求不会修改服务器资源,相对比较安全。

CONNECT:是 HTTP/1.1 协议预留的,能够将连接改为管道方式的代理服务器。通常用于 SSL 加密服务器的链接与非加密的 HTTP 代理服务器的通信。

PATCH:是对 PUT 方法的补充,用来对已知资源进行局部更新。当资源不存在时,PATCH 会创建一个新的资源,而 PUT 只会对已存在的资源进行更新。

其中 GET, POST 和 HEAD 方法是 HTTP1.0 定义的三种请求方法,在 HTTP1.1 又新增了六种请求方法,即 OPTIONS、PUT、PATCH、DELETE、TRACE 和 CONNECT 方法。如果想了解更多 HTTP 历史的朋友,可以阅读我之前的写的一篇文章 HTTP 演进史,嘿哈🙋‍。

再说一下 OPTIONS 请求,该请求与 HEAD 请求有点类似,一般也是用于客户端查看服务器的性能。

OPTIONS 方法会请求服务器返回该资源所支持的所有HTTP请求方法,该方法会用来代替资源名称,向服务器发送 OPTIONS 请求,可以测试服务器功能是否正常。JavaScript 的 XMLHttpRequest 对象进行CORS跨域资源共享时,就是使用 OPTIONS 方法发送嗅探请求,以判断是否有对指定资源的访问权限。

那么需要满足哪些条件才会触发 OPTINS 请求呢?

实例验证

在没有回答上面的问题之前,我们还是来做个实验吧~

你需要将 Chrome 浏览器的审查视图打开,最好把 Disable Cache 也勾选上禁止 Chrome 使用网络缓存,这样才不会影响下面的实验。

img

下面是 Springboot 关于登录的一个示例代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(value = "signin")
public class MSSigninController {
@RequestMapping(value = "/name", method = RequestMethod.GET)
public MSResponse sigin(@RequestParam(value = "username") String userName, @RequestParam(value = "userpwd") String userPwd) {
MSResponse response = new MSResponse();
MSUser user = null;

if (null == userName || null == userPwd || userName.length() <= 0 || userPwd.length() <= 0) {
MSResponseEnum responseEnum = MSResponseEnum.Login4SiginInvalidInfo;
response.setCode(responseEnum.getCode());
response.setMsg(responseEnum.getMsg());
} else {
user = MSUserUtil.createDefaultUser(userName, userPwd);
MSResponseEnum rspEnum = MSResponseEnum.SUCCESS;
response.setCode(rspEnum.getCode());
response.setMsg(rspEnum.getMsg());
}

response.setResults(user);

return response;
}
}

你大可不必去了解这个代码的具体逻辑,现在你只需要知道他是用来给 JavaScript 调用的一个登录API即可。

再来一个 HTML 文件,模拟请求登录的API,请求 HTTP 使用 Ajax,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";

$.ajax({
url: url,
type: 'GET',
dataType: 'json',
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

使用 Chrome 浏览器直接打开这个HTML文件即可,然后启动 Java 服务,在浏览器中点击按钮进行 GET 请求。

此时请求会报下面的错误:

1
Access to XMLHttpRequest at 'http://localhost:8080/signin/name?username=jack&userpwd=123' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

确实是造成了跨域请求,导致请求失败。

但是令人遗憾的是并没有看到发出 OPTIONS 请求,使用 Fiddler 抓包,可以看到只有 GET 请求,如图所示:

img

难道是自己写代码的姿势不对吗?!

其实,在 HTML 中使用 HTTP 请求,发生 OPTIONS 请求是需要几个条件的:

  • 1、必须是跨域请求
  • 2、自定义了请求头
  • 3、请求头中的 content-typeapplication/x-www-form-urlencodedmultipart/form-datatext/plain 之外的格式

满足1和2或者满足1和3就会发生 OPTIONS 请求,首先我们确定了上面的示例是跨域请求,但是不满足后面的两个条件之一。

我们修改一下HTML代码增加一个 content-type,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";

$.ajax({
url: url,
type: 'GET',
dataType: 'json',
contentType: 'application/json',
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

此时在浏览器中(需要使用 Chrome 的审查视图)可以看到报错信息:

1
2
3
OPTIONS http://localhost:8080/signin/name?username=jack&userpwd=123 403

Access to XMLHttpRequest at 'http://localhost:8080/signin/name?username=jack&userpwd=123' from origin 'null' 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.

抓包工具中也可以看到发生了 OPTIONS 请求,如下图:

img

也可以自定义 Header 头来进行验证,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
function getReq() {
var url = "http://localhost:8080/signin/name?username=jack&userpwd=123";

$.ajax({
url: url,
type: 'GET',
dataType: 'json',
headers: {
token: "yu7rX98xxxx_iii^ddd",
userId: 123,
openid: 231232
}
}).done(function (result) {
console.log("success");
console.log(result);
}).fail(function () {
console.log("error");
})
}
</script>

验证结果和上面一致,也会发生 OPTIONS 请求。

再聊OPTIONS

RFC2616-HTTP/1.1 中关于 OPTIONS 有详细的描述,感兴趣的可以看一下 9.2 OPTIONS 小节。

OPTIONS 请求方法的主要用途有两个:

1、获取服务器支持的 HTTP 请求方法;

2、用来检查服务器的性能,如上面例子中的AJAX进行跨域请求时的预检,需要向另外一个域名的资源发送一个 HTTP OPTIONS 请求头,用以判断实际发送的请求是否安全;

HTT P的 OPTIONS 请求,有很多地方也被称之为预请求或者预检请求,换句话说就是试探性的请求不算是正式请求。

为了避免对服务器产生一些副作用,类似上面例子中的网页中的请求就会产生 OPTIONS 请求,也算是一种对服务器的保护。只有当服务器允许后,浏览器才会发出正式的请求,否则不发送正式请求。

我们可以使用 curl 模拟 OPTIONS 请求,例如下面请求谷歌:

1
curl -i -v -X OPTIONS https://www.google.com

可以看到请求的响应情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
< HTTP/2 405
< allow: GET, HEAD
< date: Sat, 31 Aug 2019 02:17:03 GMT
< content-type: text/html; charset=UTF-8
< server: gws
< content-length: 1592
< x-xss-protection: 0
< x-frame-options: SAMEORIGIN
< alt-svc: quic=":443"; ma=2592000; v="46,43,39"
<
{ [5 bytes data]
100 1592 100 1592 0 0 13606 0 --:--:-- --:-- 13606HTTP/2 405
allow: GET, HEAD
date: Sat, 31 Aug 2019 02:17:03 GMT
content-type: text/html; charset=UTF-8
server: gws
content-length: 1592
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: quic=":443"; ma=2592000; v="46,43,39"

SpringBoot 解决跨域

话说,同源策略引起了跨域问题,本身是为了安全起见为何我们还要去解决这个问题呢?这是因为 Web 前端是我们自己开发的,也就是说我们是知道自己的 Web 请求是安全的(类似于白名单客户),就需要让它顺利访问后端服务,所以解决这个跨域问题势在必行。

解决跨越的问题,在网上有很多的路子,目前大概有下面几种解决方案,如下:

  • JSONP
    • 简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。但它仅支持GET方法不支持POST等其他请求方法,而且可能会遭受XSS攻击。
  • CORS
  • postMessage
  • websocket
  • Node 中间件代理
  • Nginx 反向代理
  • window.name+iframe
  • location.hash+iframe
  • document.domain+iframe

今天我们使用 SpringBoot 自带的注解来解决这个问题😃。

在说解决方案之前,还是先了解一下 CORS(Cross-origin resource sharing),其全称是”跨域资源共享”,是 W3C 的一个标准。

CORS 允许浏览器向跨源服务器发出 XMLHttpRequest 请求,从而克服了AJAX只能同源使用的限制。

CORS 需要浏览器和服务器同时支持。幸运的是目前几乎所有的浏览器都支持该功能,唯一美中不足的是IE浏览器的版本不能低于IE10。

实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。SpringBoot 自带注解 CrossOrigin 可以用来解决跨域问题。

修改一下 Controller 的代码,增加 CrossOrigin 注解,示例代码如下:

1
2
3
4
5
@CrossOrigin
@RequestMapping(value = "/name", method = RequestMethod.GET)
public MSResponse sigin(@RequestParam(value = "username") String userName, @RequestParam(value = "userpwd") String userPwd) {
// 省略
}

重新启动服务,抓包工具可以看到 OPTIONS 和 GET 请求都正常执行,返回码都是200。

img

可以针对某个方法添加 CrossOrigin 注解,也可以对整个 Controller 添加该注解。

关于 CrossOrigin 注解,大家可以自行实践,这里不再赘述。


一直坚持在学习的路上努力~

img


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

veryitman