XMLHttpRequest 跨域时产生了 OPTIONS 请求

【以下是转载内容】

 码字很辛苦,转载请注明来自tuy博客《XMLHttpRequest 跨域时产生了 OPTIONS 请求》

一:前言

对于跨域请求,一直没有采用jsonp方式,原因如下

1.jsonp只支持get请求而不支持post请求,如果想传给后台一个json格式的数据,浏览器会返回一个415的状态码,告诉我们请求格式不正确,这让传输大规模数据变得繁琐。

2.无法准确定位和调试请求异常情况

3.存在安全性问题(可能是我的技术盲点,因为看到很多大公司都用jsonp技术)

考虑到以上问题,并且跨域资源共享标准 允许XMLHttpRequest 或 Fetch 发起跨域 HTTP 请求,前后端约定数据请求一律采用XMLHttpRequest,通过后台设置响应报文头 Header set Access-Control-Allow-Origin *,即可实现跨域访问。为了防止XSS攻击, 我们又进行域名限制,比如 Access-Control-Allow-Origin: http://www.xudihui.com

二:正文

用了好几个项目下来,一直没出问题。今天维护老项目时,发现请求新接口并不能准确拿到业务数据,而是触发了一个OPTIONS请求,请求头如下:

1
2
3
4
5
6
7
8
9
10
11
OPTIONS http: //activity.96225.com/win_smk_activity/baseUser/getUserIdByToken.ext HTTP/1.1
Host: activity.96225.com
Connection: keep-alive
Access-Control-Request-Method: POST
Origin: http: //192.168.2.176:4000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.98 Safari/537.36
Access-Control-Request-Headers: appid
Accept: */*
Referer: http: //192.168.2.176:4000/
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8

从请求头来看,OPTIONS请求前端代码并没有发起,仔细查看请求头字段:

字段 Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法;

字段 Access-Control-Request-Headers 告知服务器,实际请求将携带一个自定义请求首部字段:appid,appid是用来告知服务端业务逻辑使用,ajax被封装之后,appid携带在该项目所有请求头中;

字段 Host 告诉我们服务器主机名;

字段 Referer 显示了本地开发地址,Host和Referer是典型的跨域请求。 

带着疑问去了解OPTIONS请求,首先查看了ajax方法,除了xhr对象序列和增加了一个请求头appId之外,并没有其它逻辑,代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
var  ajax_ =  function (obj) {
         obj = obj || {};
         obj.type = (obj.type ||  'GET' ).toUpperCase();
         obj.dataType = obj.dataType ||  'json' ;
         obj.timeout = obj.timeout || 20000;
         var  params = formatParams(obj.data);  //参数格式化
         var  xhrTimeout; //前端定时放弃
         //step1:兼容性创建对象
         if  (window.XMLHttpRequest) {
             var  xhr =  new  XMLHttpRequest();
         else  {
             var  xhr =  new  ActiveXObject( 'Microsoft.XMLHTTP' );
         }
         if (AJAX_ == 0){
           UI.showIndicator();
         }
         AJAX_++;
         //step4: 接收
         xhr.onreadystatechange =  function () {
             if  (xhr.readyState == 4) {
                  AJAX_--;
                 if (AJAX_== 0){
                   UI.hideIndicator();
                 }
                 try {
                     if  (xhr.status >= 200 && xhr.status < 300) {
                         obj.success && obj.success(xhr.responseText, xhr.responseXML);
                     else  {
                         obj.error && obj.error(xhr.status);
                     }                     
                 } catch (e){} 
             }
         }
         //step2 step3:连接 和 发送
         if  (obj.type ==  'GET' ) {
             xhr.open( 'GET' , obj.url +  '?'  + params,  true );
             xhr.setRequestHeader( 'appId' , $APPID);
             xhr.send( null );
         else  if  (obj.type ==  'POST' ) {
             xhr.open( 'POST' , obj.url,  true );
             //设置请求头,以表单形式提交数据
             xhr.setRequestHeader( 'Content-Type' 'application/x-www-form-urlencoded' );
             xhr.setRequestHeader( 'appId' , $APPID);
             xhr.send(params);
         }
          // 超时,默认20秒,直接设置timeout属性:https://www.w3.org/TR/2012/WD-XMLHttpRequest-20120117/#handler-xhr-ontimeout
          xhr.timeout = obj.timeout;
          xhr.ontimeout =  function (){
             UI.toast( '请检查网络连接是否正常' ,2000, 'exception' );
          }
 
         //辅助函数,格式化参数
         function  formatParams(data) {
             var  arr = [];
             for  ( var  name  in  data) {
                 arr.push(encodeURIComponent(name) +  "="  + encodeURIComponent(data[name]));
             }
             //设置随机数,防止缓存
             arr.push( "t="  + Math.random());
             return  arr.join( "&" );
         }
     }

通过stackoverflow的这篇文章,得知我遇到的OPTIONS是浏览器发起的'preflight'请求,征求服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。但是我其它项目中并没有产生OPTIONS请求,它是从哪里来的呢,继续查看触发条件,当满足以下条件时,浏览器主动触发OPTIONS请求:

1、使用了下面任一 HTTP 方法:

  – PUT

  – DELETE

  – CONNECT

  – OPTIONS

  – TRACE

  – PATCH

2、人为设置了对 CORS 安全的首部字段集合之外的其他首部字段。该集合为:

  – Accept

  – Accept-Language

  – Content-Language

  – Content-Type (but note the additional requirements below)

  – DPR

  – Downlink

  – Save-Data

  – Viewport-Width

  – Width

3、 Content-Type 的值不属于下列之一:

  – application/x-www-form-urlencoded

  – multipart/form-data

  – text/plain

条件1并没有匹配,前端采用标准的POST请求;条件三也没有匹配,请求头Content-Type也是标准值:xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');

主要原因就是条件2,我们定义了非安全的首部字段appId,设值的初衷,就是让服务端通过此它能更方便地来做业务逻辑。

在前端把xhr.setRequestHeader('appId', $APPID)去掉之后,浏览器不进行OPTIONS服务器预检测,直接进入业务逻辑,正确发送POST请求,并且跨域成功。

到此处,其实仅仅解决我们的跨域问题已经完成了,只要把appId放到请求send数据中去,移除头部appId字段,就能成功进行前后端交互了。但appId这个字段当初放在头部,就是考虑到它有别于业务逻辑,跟业务代码一起放在请求体中不是特别合适,决定再看看有没有其它方法,允许设置自定义头并且成功跨域的方案。

感谢mozilla developer,提供了关于跨域非常详细的资料,大家可以看看。里面也提到了我当前场景的解决方案,马上着手开始实践,为了创造一个跨域环境,我先用nginx起一个127.0.0.1:8080的服务器,然后在这个地址中使用XMLHttpRequest 对象,发起目标为192.168.1.6地址的请求,192.168.1.6使用node js创建一个带有基础响应头的服务器,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const http = require( 'http' );
const hostname =  '192.168.1.6' ;   //使用ipconfig -all查看本机IP地址,然后使用127.0.0.1地址进行访问
const port = 3000;
 
//支持跨域的服务器搭建
const server = http.createServer((req, res) => {
   res.statusCode = 200;
   res.setHeader( 'Content-Type' 'application/json;charset=UTF-8' );
   res.end( '{"code":-3,"msg":"APPID不能为空","response":null,"systemDate":"2017-07-13"}' );
});
server.listen(port, hostname, () => {
   console.log( 'Server running at http://' +hostname+ ':' +port);
});

使用fiddler查看报文如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//请求报文如下:
OPTIONS http: //192.168.1.6:3000/ HTTP/1.1
Host: 192.168.1.6:3000
Connection: keep-alive
Access-Control-Request-Method: GET
Origin: http: //127.0.0.1:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.98 Safari/537.36
Access-Control-Request-Headers: appid
Accept: */*
Referer: http: //127.0.0.1:8080/dev/SVN_/h5Main/trunk/alipay/virtualCard20170509/src/pages/test/3-new.html
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8
 
//响应如下:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Fri, 14 Jul 2017 15:11:34 GMT
Connection: keep-alive
Content-Length: 79
{ "code" :-3, "msg" : "APPID不能为空" , "response" : null , "systemDate" : "2017-07-13" }

可以看到通过在前端增加appid请求头,访问node js 搭建的服务器,浏览器触发了OPTIONS 预检验请求,但是服务端的响应头中没有设置Access-Control-Allow-Origin也没有允许OPTIONS请求,它却成功跨域拿到了json数据。发生这种情况首先想到的就是服务器环境不一致,我们后台使用的是java的spring mvc 肯定比node js 复杂千万倍。于是把这个问题反馈给后台同学,他们通过审查配置文件后发现,OPTIONS在java 的 spring MVC 框架中默认是禁止放行的。资料显示,框架认为这是不安全的行为,确实,不仅跨域还添加自定义请求头。于是开始改造后台响应报文,我配合后台一起调试。最后在原有基础上增加如下配置:

'Access-Control-Allow-Headers', 'appId' 来允许服务器请求中携带字段appId,如果还有其它字段,可以用逗号分隔填入;

'Access-Control-Allow-Methods',': POST, GET, OPTIONS'来允许服务器允许客户端使用 POST, GET 和 OPTIONS 方法发起请求;

添加完毕之后,响应头中增加对应字段,可以成功实现带自定义首部字段的跨域通信。

1
2
3
4
5
6
//响应头局部:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Access-Control-Allow-Headers: appId
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Origin: *

但是我们发现每次这种情况都会触发OPTIONS请求,然后再去执行业务逻辑,虽然正常执行了,但是一个请求变成了两个,肯定增加了用户等待时间和服务器资源消耗,于是又在响应头中增加了Access-Control-Max-Age: 86400;表明该响应的有效时间为 86400 秒,也就是 24 小时。在有效时间内,浏览器无须为同一请求再次发起预检请求。浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。最后同一天内一个接口就只有一次OPTIONS请求啦,大功告成!

三:参考资料 stackoverfloww3 跨域文档HTTP访问控制

猜你喜欢

转载自blog.csdn.net/qq649954944/article/details/80241556
今日推荐