Javascript Ajax 跨域之 CORS 实战
众所周知,在javascript中使用ajax请求外部资源时有跨域限制。我们在讲解实战之前有必要搞清楚浏览器为什么要加这个限制,也就是要知其然也需要知其所以然。
不过,我们还应该先了解Cookie,在一个已经登录过的站点,浏览器会保存一个登录凭证,这个凭证就是保存在cookie中的其中一个值。有了登录凭证,在短时间内是不需要重新登录就可以访问该站点上属于用户的任何内容的。当我们登录了一个站点A,上面有一个按钮,其中显示“抽奖”,你也许会去点一下碰碰运气。但是这个抽奖按钮实际执行的动作也许你并不能从页面信息中准确无误的获取到,那如果这个按钮的背后行为是从你的银行账户转出一笔金额到一个盗窃者账户,现在你还敢点那个按钮吗?
当然要实现上面的这样的伪造动作,还有别的方式。比如放一个 链接,再比如放一个表单进行提交,这两种都是经常被大家提起web相关的安全防范要点。作为一个web开发者,我们如何防范这两种情况的发生呢?那就是为每一个用户生成一个短期有效的hash值,在每一个修改数据的请求中验证这个值是否正确。浏览器标准协议为什么不针对这些情况,进行限制同源策略呢?我猜想是不太好限制,毕竟多个站点间还是需要相互跳转的,因此就需要开发者多加注意。另外,为了让用户有感知这两种情况发生,进而做出选择,当这些情况发生时浏览器地址栏中的地址均会变为上面设置的 url ,这就是需要用户在浏览web页面时需要多加注意,要有识别是否是钓鱼网站的能力。当然在一些安全要求高的场景,比如支付、转账,都会要求再次输入密码,确认是本人主动行为。部分浏览器制造厂商也在地址栏中加入了“绿色”标志,安全、有信誉的网站会标记出来。此外,百度搜索结果中也会标记一些站点是否可能是钓鱼网站。
页面中的 Ajax 操作,对于普通用户来说真的是神不知鬼不觉,根本不知道背后在发生着什么。如果不对它进行一定的安全限制,它的危害还是非常大的。你可能想问,上面不是说了那么多防范措施了吗?但是可以肯定的是,不是所有站点维护人员都有这么高的安全意识,也不是所有的开发人员都有这么多的技术积累。最终,制定web标准的相关人员,不得不做出一个艰难的决定。。那就是假设一个站点只获取当前站点的数据就够了,没必要获取其他站点的数据显示在页面中,进而禁止发出跨域请求。最后,各个浏览器厂商就按照web标准实现了这个限制策略。
既然有了限制,作为一个站点开发者,我们为什么要违背web标准,去执行跨域操作呢?那是因为现在互联网发展的是在是太快了,20年前的web页面与现在的web页面差太多了,之前可能只有文字,或许还有少量的图片,但是现在图片、视频、语音实在是太多了。现在的上网用户也越来越多了,我们这么多资源需要给这么多的用户浏览,就需要大量的团队、服务器的支撑。现在我们知道的微服务架构就是在这样的大背景下应运而生,为了更好的人员管理,也是为了取得更好性能的一种架构设计。所以,在一个域名下,可能需要请求其他域名下的资源,这些域名可能是属于同一个公司或者组织。
常见的 js 跨域请求,有2种处理方式,第一种,JSONP,第二种,CORS 。JSONP 技术是利用已一个巧妙的方式实现的,并没有额外高深的技术实现,也没有前端同学担心的浏览器兼容问题。而是,利用了在一个页面中引用的任何域名下的js,都可以调用这个页面上的其他js方法这样一个投机的方法。第一步,js 先定义一个唯一的js function,然后把这个function name做为callback的参数拼接在要跨域请求的url后面,作为的值,追加在页面中,这时浏览器就会发起对这个地址的请求。第二步,服务端收到callback参数后,把要返回的数据使用callback参数包装成一个函数调用,然后返回。第三步,之前定义好的那个函数这个时候会被调用执行,调动函数的参数是之前跨域请求的输出,js通过解析这个输出字符串来执行其具体业务逻辑。至此,一个跨域请求完成,需要客户端、服务端配合实现,但是浏览器兼容性非常好,但是,根据原理,我们知道 JSONP 是一个GET请求实现的,对于要进行提交大数量交互的请求就不是那么友好了。另外,对应的服务端可以根据referer值来进行判断,允许哪些站点通过 JSONP 来调用自己的资源。
另外一种就是,CORS (cross origin resource sharing) 跨站资源共享。是通过浏览器内核与http 请求、响应头一起实现的,所以需要浏览器的支持,同时需要web服务器的相应配置。好在现在的android、ios 内置的浏览器内核很早就已经支持了这个实现,在移动端基本不用担心这个问题,在PC端只要不是非常旧的版本也基本都实现这个针对CORS的支持。传说中的IE 6、7就不支持,IE8、9是借助XDomainRequest对象完成的,IE10提供了对规范的完整支持。关于更详细的CORS相关的知识,可以参考mozilla的相关文档
当我们发送一些简单请求时,浏览器会直接发送,只会在请求头中增加 Origin。如果是非简单的请求,会先进行一次预检,确认对应服务器是否允许Js 跨域访问,如果允许再发送一次真正的请求,否则在浏览器的控制台会出现错误信息。最典型的可以分为这么3种场景来描述:简单请求、非简单请求、带有cookie的请求。
第一,简单请求, 关于简单请求的定义在上面提到的mozilla的文档中有详细的说明,不过可以大致概括为这样: GET/POST/HEAD 请求,且请求头没有自定义字段与值,整体请求没有太多的定制化。 发送一个请求时,浏览器内核会在请求头中添加 Origin ,不需要另外的代码支持,其值是当前页面对应的的协议、域名、端口三者的组合,如:Origin: https://www.abc.com 。 服务端收到请求时可以根据这个请求头来判断是否允许该站点的跨域调用,如果允许,则需要在响应头中加入 Access-Control-Allow-Origin: * 。Access-Control-Allow-Origin 有2种取值,要么为 * ,代表允许所有站点,要么为请求头中 Origin 的值,且必须与 Origin 的值完全相同。因此,如果我们要对某些站点允许的话,只能动态根据请求头中的Origin值来设置 Access-Control-Allow-Origin 的值。另外,需要注意的是,Access-Control-Allow-Origin 这个值是不允许定义多次,也不允许其对应的值有多个的。
第二,非简单请求,关于非简单请求的定义在Mozilla的文档中也是有详细的说明,我们依然可以简单理解为除简单请求之外其他均为非简单请求。比如,请求方法是 PUT/DELETE 等,或者是添加了自定义请求头,这两种是我们比较常见的情况。浏览器在发送真实的请求前会先发送一次 OPTIONS 请求看看服务端是否允许这种跨域请求,这个请求叫做“预检请求”,英文是 preflight request。浏览器检测到允许后,接着发送一次真正的请求,否则会在浏览器的控制台中打印错误信息。 在 OPTIONS 请求中,浏览器会额外增加这些请求头:
Access-Control-Request-Headers: tk Access-Control-Request-Method: GET Origin: https://www.abc.com如果说我们在请求头中添加了自定义请求头,那么浏览器会在 OPTIONS 中额外增加这个请求头: Access-Control-Request-Headers,否则不会发送这个请求头。其中 tk 就是我们要增加的请求头字段,看到在 OPTIONS 请求中实际不会发送tk对应的值。你还可以定义tkk, tkh ,那都没有问题。 对应的服务端可以这么设置响应头:
Access-Control-Allow-Headers: tk Access-Control-Allow-Methods: GET, POST, DELETE, PUT, OPTIONS Access-Control-Allow-Origin: * Access-Control-Max-Age: 60如果自定义了请求头,那么在OPTIONS 请求的响应头中 Access-Control-Allow-Headers 是必须的,其值就是这些自定义请求头字段,如果有多个,可以使用逗号分隔。 在OPTIONS 请求的响应头中 Access-Control-Allow-Methods 这个则是必须要设置,定义一些我们允许的方法。 在OPTIONS 请求的响应头中 Access-Control-Allow-Origin 之前有解释,这个也是必须设置。 在OPTIONS 请求的响应头中也可以设置 Access-Control-Max-Age ,比如上面其值是 60,代表在60秒内再次发送相同的请求时不需要再次发送OPTIONS请求(预检)。如果不设置,默认是每次请求都进行预检。
第三,带有cookie的请求,默认情况下,发送跨域的Ajax请求时是不会携带对应域下的cookie信息的。如果需要,那么请求方与服务端都需要相应的设置。 请求方需要设置 xhr.withCredentials 值为 true。如下:
var xhr = new XMLHttpRequest(); xhr.withCredentials = true; ... xhr.send();对应服务端需要额外增加响应头,如下:
Access-Control-Allow-Credentials: true另外,服务端响应头中的 Access-Control-Allow-Origin 不可以设置为 * 了,必须是与请求头中的 Origin 字段值完全相同。
总结:
1. 真正的请求中支持的请求头、响应头分别如下:
请求头:
Origin: https://www.abc.com
响应头:
Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: https://www.abc.com Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
2. 预检请求中支持的请求头、响应头分别如下:
请求头:
Access-Control-Request-Headers: tk Access-Control-Request-Method: GET Origin: https://www.abc.com
响应头:
Access-Control-Allow-Credentials: true Access-Control-Allow-Headers: tk Access-Control-Allow-Methods: GET, POST, DELETE, PUT, OPTIONS Access-Control-Allow-Origin: https://www.abc.com Access-Control-Max-Age: 60
3. 在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的响应头,如果要访问其他头,则需要服务器设置 Access-Control-Expose-Headers 响应头。
4. 如果是需要传递cookie,那么请求方与服务器方都需要配置 Credentials 相关的值。另外,服务端就不能再把 Access-Control-Allow-Origin 设置为 * ,而是需要一个固定值。
5. 有些响应头是支持多值,使用逗号分隔的,但是有些是不支持的,一定要注意。其中 Access-Control-Allow-Origin 这个响应头就是不支持多值的,另外,也不能够写多条,如果这么做,浏览器检测到之后也会提示错误。
6. 如果触发了预检,对应的检验策略会在浏览器收到OPTIONS请求结果时进行处理,比如,Access-Control-Allow-Origin ,Access-Control-Allow-Credentials 等这些值一旦验证认为服务端不允许,那么浏览器将在控制台给出错误信息,接着就结束本次的Ajax调用,不会再次发起真正的请求。
7. 无论浏览器是否允许,简单请求与预检请求,服务端都会收到请求,拦击的动作是发生在浏览器收到响应结果之后。这时Javascript 代码是捕获不到的,但是服务端是确实有收到请求,所以需要考虑是否给对应的服务造成了影响。
接着,这里提供下实例代码:
前端Js代码:
<script src="https://static-bbs.letv.com/js/mobile/jquery-1.8.3.min.js"></script> <script> jQuery.ajax({ url: "http://www.cde.com/aaaa", headers: { "tk": "abc1123" }, xhrFields: { withCredentials: true }, cache: false, success:function (result) { console.log(result); }, error:function(xhr, status, error) { console.log(xhr); console.log(status); console.log(error); } }); </script>
服务端配置:
server { listen 80; server_name www.cde.com; root /letv/www/cde/public; index index.php index.html index.htm; add_header Access-Control-Allow-Origin * always; add_header Access-Control-Allow-Credentials false; location / { if ($request_method = 'OPTIONS') { add_header Access-Control-Max-Age 60; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Credentials false; add_header Access-Control-Allow-Methods "GET, POST, DELETE, PUT, OPTIONS"; add_header Access-Control-Allow-Headers "tk"; return 204; } if (!-e $request_filename) { rewrite ^/(.*)$ /index.php; } } location ~ ^.*\.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }
关于Nginx的配置,需要关注这么几点:
1. 首先是 location 匹配的优先级。
2. 其次,add_header 可以使用在这些Context : http, server, location, if in location ,所以如果要在 if 中使用 add_header, 必须要是在 location 中。
3. 另外,add_header 指令是可以自动继承之前作用域的定义,但是如果当前的作用域只要定义一个 add_header 指令,继承的 add_header 将全部失效。
4. 最后,add_header 指令只有在响应码是 200, 201 (1.3.10), 204, 206, 301, 302, 303, 304, 307 (1.1.16, 1.0.13), or 308 (1.13.0) 的时候才会真正把指定的字段添加到响应头。从 1.7.5 版本以后,可以使用always 参数,这时所有的响应码都会生效。