01月03, 2023

谈谈跨域

问题的由来

什么是跨域资源共享

根据MDN的表述,跨域资源共享(Cross-Origin Resource Sharing,简称CORS),一般也称为跨域请求,是一种基于 HTTP 头的机制,通过这种机制,服务器可以通知浏览器,允许非同源访问加载该服务器域内的资源。

其实这个表述还有一层隐含意思,就是如果服务器没告诉浏览器这些源可访问,那么浏览器就会禁止这个域访问该资源。

但是,有很多能够发起请求的客户端,比如命令行程序、比如 Postman,其实是可以随意的获取各个域名下的资源的。跨域资源获取,是一种浏览器的机制。如 Chrome ,也是可以关掉跨域限制的。

以 Mac 系统为例,使用下面这个脚本启动 Chrome,就可以关掉跨域限制,肆无忌惮地访问其他域名的资源的。

#!/bin/bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --args --disable-web-security --user-data-dir

将上述脚本存为 chrome.unsafe,同时给予执行权限。这时打开的 Chrome 似乎就可以大杀四方、畅通无阻,连健康码、行程码都不用扫了。这就是所谓躺平版 Chrome。

确切地说,CORS是浏览器为了达到获取跨域资源,而提供的一种机制。它的诞生实际是为了绕过另一种浏览器安全机制——同源策略。

什么是同源策略

1995年的时候,当时引领浏览器发展潮流的网景公司在 Netscape 中引入了同源策略。现在,你依然可以在互联网上找到同源策略的标准。所谓同源策略,其实在互联网发展的过程中,已经变得越来越严格。目前的同源策略主要指,对于非同源的请求:

  1. Cookies、LocalStorage 和 IndexDB 等无法读取。
  2. DOM 无法获得,比如非同源的 Iframe,不能互相获得 Dom 内容。
  3. Ajax 请求不能发送。

这么多限制,不使用同源策略行不行。我们的躺平版Chrome能不能作为Chrome的标准配置?

答案当然是不行的。

如上图所示,如果不加限制地允许跨域访问。那么,假如在我 evil.com 域名下的页面上有一段脚本。标签中,我写了一段向银行的 DELETE /account 接口发起请求的代码。由于我们假设浏览器允许各种跨域请求,所以每当你访问这个页面时,都会有个 AJAX 请求悄悄地调用银行的 API。于是,看了一会网页,你的账户就被删除了。

这当然是不能容忍的。

所以,我们既要安全,又要功能。对于这种既要又要的需求,我们作为工程师,只能满足。上面的三条限制,想必大家或多或少的了解,这里重点提一下第三点。

自2005年左右谷歌地图爆火,基于Ajax的前端应用呈爆炸式增长。如何跨域取得数据,这也就成了无数前端工程师的噩梦。

一种方案是,在同域下撰写服务端接口,做真实接口的代理访问。事实上,很多早期的系统正是这么做的。具体方案是在同域下做一个接口代理,然后由这个服务端接口去请求正式的接口。由于服务器不受同源策略限制,所以可以取得数据,再由这个代理接口返回。这种做法一是出现很多不必要的代码,一是对于浏览器本身的和用户浏览器相关的数据如:IP、Cookies等等不易被简单的传递。

还有一种方案是使用JSONP。本质是动态创建script标签,src不受同源策略限制。籍此,向服务器请求JSON数据,服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。这种方案可以解决大部分GET请求的问题。不过也就仅此而已,POST等需求是束手无策的,而且超时检查也需要自己完善。

再有一种是用WebSocket方案。这个协议中,请求的头信息中可以指定Origin字段。服务器可以依此来判断是否给出资源。因此,浏览器放过了这个协议,它不用遵守同源策略。

再有就是大杀招:CORS了。

CORS细节

一点历史

跨源支持最初由Tellme公司的Matt Oshry、Brad Porter和Michael Bodell在2004年3月提出,允许VoiceXML浏览器安全跨源数据请求。提出后,专家们认为这个是个普遍性的需求,不应该VoiceXML独有,随后被分离出来,变成一份实现参考。于是W3C的WebApps工作组在主要浏览器供应商的参与下,开始将正式化为W3C工作草案,朝着正式的W3C推荐状态迈进,在2006年5月,提交了第一份W3C工作草案。 2009年3月,草案更名为“跨源资源共享”,2014年1月被接受为W3C建议。

这也解释了早期的浏览器如IE8、IE9等等不支持CORS的原因。而目前市面上主流的浏览器,均对CORS做了完整的支持。

主要的运作机制

何时需要CORS

  1. XMLHttpRequest 或 Fetch API 发起的跨域 HTTP 请求,这两个是现在发起Ajax请求的基础。
  2. Web 字体(CSS 中通过 @font-face 使用跨源字体资源)。如,引入360守护体:
<style type="text/css">
@font-face{font-family:'shouhuType';src:url('http://cn-zhengzhou-1.xstore.qihu.com/doravis_fonts/360shouhuType-Regular.otf')}
</style>
  1. WebGL 贴图的JavaScript代码
function initTextures() {
  cubeTexture = gl.createTexture();
  cubeImage = new Image();
  cubeImage.onload = function() { handleTextureLoaded(cubeImage, cubeTexture); }
  cubeImage.src = "cubetexture.png";
}

function handleTextureLoaded(image, texture) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
  gl.generateMipmap(gl.TEXTURE_2D);
  gl.bindTexture(gl.TEXTURE_2D, null);
}

4.Canvas 使用 drawImage() 将图片或视频画面绘制到 canvas。

CanvasRenderingContext2D.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

  1. 来自图像的 CSS Shape,如:
shape-outside: url(https://s3-us-west-2.amazonaws.com/s.cdpn.io/123941/css-shapes-9.jpg);

从引入CORS开始,理论上,跨域的请求都会经过预检(CORS-preflight)、请求的过程。但事情并不是绝对的。为了简化服务端的工作,其实有一部分请求是不需要做预检的。

简单请求

简单请求并不会发出预检请求。这种情况是传统html标签,主要是<form>标签的延伸。也就是说,这些请求无需任何脚本,也都可以向任何域提交简单请求。后面会提到,表单提交其实也是有CSRF(跨站伪造)的可能的,允许了简单请求的简化处理,并不会把问题变得更糟,但是,服务端开发者需要留意这种攻击的可能性。

判定一个请求是简单请求的标准为:

  1. 请求方法是以下三种方法之一:HEAD、GET或者POST
  2. HTTP的头信息不超出以下几种字段:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

当一个请求是简单请求时,浏览器直接发出CORS请求,仅在头信息之中,增加一个Origin字段,这和传统的表单提交还是有一定的区别。比如:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
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
Connection: keep-alive
Origin: https://foo.example

服务器收到请求,应该去检查Origin字段。如果Origin指定的源,在许可范围内,服务器会返回一个正常的HTTP回应,在回应头中,增加Access-Control-Allow-Origin字段,标识允许哪个域的请求。如果没有这个字段或者域不在这个范围内,则浏览器会报错,且不会返回内容。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

上面的代码就是针对浏览器的简单请求,服务器作出响应的例子。

整个请求的流程,我们可以归结为如下的时序图:

预检请求

除了上面提到的那些请求方法,其他的请求都需先发预检请求。“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到目标服务器,以获知服务器是否允许该实际请求。

比如如下的前端代码:

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bar.other/resources/post-here/');
xhr.setRequestHeader('X-PINGOTHER', 'pingpong');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<person><name>Arun</name></person>');

使用XHR进行POST请求,该请求包含了一个非标准的 HTTP X-PINGOTHER 请求首部。这样的请求首部并不是 HTTP/1.1 的一部分。另外,该请求的 Content-Type 为 application/xml,且使用了自定义的请求首部,所以该请求需要首先发起“预检请求”。

此时,对应的 HTTP 请求头为:

OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
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
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

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

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

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

服务端如果能够通过,则可能发送下面的响应:

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

服务器的响应携带了 Access-Control-Allow-Origin,从而限制请求的源域。同时,携带的 Access-Control-Allow-Methods 表明服务器允许客户端使用 POST 和 GET 方法发起请求。Access-Control-Allow-Headers 表明服务器允许请求中携带字段 X-PINGOTHERContent-Type。与 Access-Control-Allow-Methods 一样,Access-Control-Allow-Headers 的值为逗号分割的列表。

Access-Control-Max-Age 给定了该预检请求可供缓存的时间长短,单位为秒,默认值是 5 秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。以上例子中,该响应的有效时间为 86400 秒,也就是 24 小时。

需要注意的是,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效,同时你在使用Devtool时候,如果你打开了“禁用缓存(当DevTools已打开时)”,这个时间将不再生效。

具体到Chrome和blink,这个时间是10分钟。也就是说,如果你给出的时间超过10分钟,浏览器也会忽略你的设置。

上述表述,可以用下面的时序图说明:

前端和服务端的常见做法

前面的论述中我们发现,在发起跨域请求的过程中,需要前端和服务端,针对协议进行配合,以完成跨域资源共享。

由于跨域请求的发起和响应,从前端的角度看,大多是浏览器自动完成的。因此对于前端,更多的是能够将必要的前端数据送过去,比如必要的 Cookies 数据。

默认地,CORS 不发送 Cookies 和 HTTP 认证信息。前端目前主流的请求发起类库,主要是对 XHR 和 fetch 的包装。

对于 XHR , 可以使用:

const xhr = new XMLHttpRequest();
xhr.withCredentials = true;

来打开跨域 Cookies 发送。

对于 fetch ,可以使用:

const url = 'https://some-url';

fetch(url, {
    credentials: 'include', // omit、same-origin或者include
}).then((res) => {
    return res.text();
}, (err) => {
    console.error(error)
})();

此时,需要服务端配合,响应的Access-Control-Allow-Origin不能设为星号,必须指定明确的、与请求网页一致的域名,否则将不会发送 Cookies。

从前端视角来看,除了上述提到的请求头,以及自定义的请求头,这里 列出了所有可用的请求头。

从服务端视角来看,需要根据 CORS 协议以及接收到的请求头,做好 HTTP 响应头字段的设置。除了上述提到的 HTTP 响应头字段,这里 列出了所有可用的响应头。

服务端需要通过服务端语言或者服务器软件的配置方法,按照 CORS 协议的内容,设置好正确的响应头。

例如现在最常用的 Nginx 服务器,它的配置就是在对应的位置,增加配置:

    add_header 'Access-Control-Allow-Origin' 'https://foo.com';
    add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'Origin,X-Requested-With,Content-Type,Accept,Authorization';

这样就实现了对响应头的设置。

几乎所有的主流服务器语言都可以设置HTTP响应头。开发者也可以在语言内进行对应的HTTP头设置。

跨域伪造攻击以及防范

前面提到了,传统的表单提交有 CSRF 风险。其实不光表单有这种风险,其他的请求方式都可能引发漏洞。这部分内容其实和CORS关系不大,但却是跨域资源访问中一个很难绕过的话题。

要完成一次 CSRF 攻击,受害者必须依次完成两个步骤: 1.登录受信任网站 A,并在本地生成 Cookie。 2.在不登出 A 的情况下,访问危险网站 B。

怎么构造攻击呢?

第一,某银行有漏洞页面:https://www.mybank.com/Transfer.php?toBankId=1234567&money=1000目标是转1000块给账号1234567。当然,银行是会验证当前用户信息的。

第二,黑客构造页面,比如伪装成砍一剑页面https://kanyijian.com/kanyijian.html。然后在一个比较冷僻的角落加上一个1*1图片, 如 <img style='width:1px;height:1px;' src=https://www.mybank.com/Transfer.php?toBankId=1234567&money=1000>

第三,把砍一剑页面分享出去。

如果点进来的用户,恰巧登录了这个银行的页面,就会发起转账的请求。黑客就有机会收到钱,受害者被着实砍一剑。

当然现实中,银行不会这么傻,比如他们常常会借助手机验证码。但是如果配合着IM聊天,索要验证码,事实上是有机会完成攻击的。当然,那已经脱离技术范畴了。

即便限制使用 POST 方式来进行提交。攻击者依旧可以使用构造表单,然后使用脚本自动提交作为攻击手段,如:

<html>
  <head>
    <script type="text/javascript">
        const steal = () =>{
         iframe = document.frames["steal"];
          iframe.document.submit("transfer");
     }
    </script>
  </head>

  <body onload="steal()">
      <img src="https://kanyijian.com/kanyijian.gif"/> <!--放一个砍一剑动画,掩人耳目--> 
    <iframe name="steal" display="none">
      <form method="POST" name="transfer" action="http://www.myBank.com/Transfer.php">
        <input type="hidden" name="toBankId" value="1234567">
        <input type="hidden" name="money" value="1000">
      </form>
    </iframe>
  </body>
</html>

攻击有一个重要的要素:在受害者不知道的情况下,使用受害者请求凭证,发起伪造请求。

针对“自动”,主要的防范也就是在“自动”不能获取上做文章。

首先是,CORS 要适度从严,坚决杜绝:Access-Control-Allow-Origin: *

其次,可以给每个表单一个服务端生成的token,作为令牌。前端收到后放到表单域,一并提交。服务端收到后,对此进行合法性验证。回想下,当初Visual Studio的拖出的aspx的表单控件,就默认带有这个token字段。

当然其他的技术栈也可以实现类似的技术,不过,要么获取 token 和表单渲染是一个不可拆分的原子流程。这样,保证攻击者没有机会插入进来。要么,保证获取token的流程在全局不可见,最好是使用即时执行。否则容易被外部攻击者覆盖或入侵。

第三,如果可能,在关键流程 API,使用图形验证码和短信验证码(前文提到银行的做法),保证关键流程用户充分知情。

参考资料

  1. https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
  2. https://www.ruanyifeng.com/blog/2016/04/cors.html
  3. https://dev.to/lydiahallie/cs-visualized-cors-5b8h?utm_campaign=React%2BNative%2BNow&utm_medium=web&utm_source=React_Native_Now_69#cs-cors
  4. https://www.modb.pro/db/421531

本文链接:https://www.leon82.com/post/tan-tan-kua-yu.html

-- EOF --

Comments