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请求,请求头如下:

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之外,并没有其它逻辑,代码如下:

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创建一个带有基础响应头的服务器,代码如下:

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查看报文如下:

//请求报文如下:
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 方法发起请求;

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

//响应头局部:
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访问控制

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

评论