在浏览器的使用中,缓存通常是其中重要的一环。对于缓存的好的利用,可以缓解服务器的压力、提升我们的网页性能和减少我们的带宽消耗。但是缓存有时也是不可控的一个存在,如果利用不好,在问题的排查中也会给我们造成很多的困扰。最近笔者就在自己的日常问题排查中遇到了这样一个问题。
浏览器版本:Chrome 102
问题现象:页面的多个 css 样式资源及 js 资源报告 CORS Error,显示资源跨域,加载失败。但是在无痕浏览器的模式下是可以正常加载的,域名实际上是一致的,在浏览器内直接请求资源或者更换其他浏览器也是 OK 的。
问题分析:在上面的问题现象中其实我们就了解了,归根结底,这个问题不在跨域上,那么这个问题究竟是为什么呢。既然这个问题在无痕模式下是 OK 的,我们就先来了解一下无痕模式。
无痕模式
浏览器一般情况下都会有无痕模式,无痕模式和正常模式会有以下的区别:
我们在无痕情况下进行浏览时,我们的一下信息不会被浏览器保存
- 你的浏览记录
- 你的 Cookie 和网站数据
- 你在表单中输入的信息
- 你为网站授予的权限
从上面呢,我们可以看到,这些信息一般是会被保存在浏览器缓存之中的。除此之外,我们在浏览器上启用的扩展插件也不会被使用。
那么我们可以总结出,在浏览器中,缓存和插件是无痕模式和正常模式比较重要的两个差别。在这之后,笔者关闭了浏览器上的所有插件,但是跨域报错依旧存在,因此决定从缓存这个角度开始排查。
浏览器缓存
那说到浏览器缓存,我们一般除了上面的用户信息之外,还会缓存我们的静态资源和请求。所谓网页的静态资源就是 Css、js 文件还有一些图片及文本。为了提升响应的性能,我们一般会将这些资源设置一个过期时间,如果这些资源在过期时间内,那么就会从浏览器缓存内拿这些静态资源。所以笔者尝试着清除了一下浏览器缓存,果然资源可以正常加载了。但是还有一个问题困扰着我,为什么缓存的错误会上报 CORS 跨域的错误信息呢。
浏览器缓存和跨域
带着重重的疑惑,笔者开始搜索浏览器缓存和跨域的相关信息,最终找到了一个可能原因。一般来说,我们在配置允许跨域的请求头时,会带上
origin
这个请求头。然后服务器在返回时会在返回头上带上
Access-Control-Allow-Origin:true
这个返回头。代表目标的域名允许跨域。
Chrome 在第二次请求数据的时候,依旧会从缓存里面拿之前的请求,但是这个请求是没有 Access-Control-Allow-Origin这个返回头的,因此浏览器会认为这个请求跨域了。
但是这个依旧没有解答笔者的疑惑,因为已经确认了域名其实并没有发生改变,那么到底为什么这里会报跨域呢。
PNA 错误和浏览器缓存
最终的最终,笔者发现,这个问题是出在 Chrome 102 上,在其他的浏览器中都没有复现。于是去查看了 Chrome 102 的一些文章,找到了答案。
Chrome 102 在网络检查上做了较大的改动,尤其是 PNA(Private Network Access) 策略。现在如果访问私域网络,即使是相同域名,也会按照 IP 来进行计算。也就是说,如果你在缓存里面存储的是一个 IPa,但是你访问的时候使用了 VPN 或者当前这个域名的 IP 改变了,就会判断当前请求的这个域名和缓存中的不相符,从而判断成为跨域。
下面我会详细介绍 Chrome 102 在 PNA 上的改变。
Private Network Access
首先,我们来解释下什么是 PNA。它的全称是Private Network Access 会被简写为 PNA,翻译是私有网络访问。什么是私有网络访问呢,是指一些个人组织或者政府组织中,比如公司的内网,不希望在外部被访问到,就会加一个防火墙进行限制。这个站点就只能在公司的局域网或者使用 VPN 进行访问,这个就是私域网。
CORS 规范中对于私域站点访问的规定和正常网站的访问相同,都是使用域名来判断同源的。而 Chrome 团队一直不赞成专用网络访问规范中对于直接从公网访问专用私网端点的规定,因为这种方式没有办法规避 DNS Rebinding 攻击。因此 Chrome 在访问私域网资源请求之前开始进行 CORS 预检查时会带上一个新的请求头 Access-Control-Request-Private-Network: true,返回头上也必须带上一个返回头 Access-Control-Allow-Private-Network: true。
这样做的目的是为了防止用户受到通过路由或者其他电子设备对于私网的跨域攻击。这已经影响到了成千上万的用户,这种攻击会将他们导向钓鱼网站。
请求预检
预检机制是由 CORS 标准发起的,在跨域的情况下在网站发起 HTTP 请求前做一个检查,确定目标网站是否可以跨域,用来避免副作用。这同时也确保了目标服务器可以理解 CORS 协议并且可以显著降低 CSRF 攻击的风险。
这个接口会在发送正式请求之前,先发送一个带有 CORS 请求头的 OPTION 请求,而返回头里也会带有一个特定的字段来标识这个将要发送的 HTTP 请求是被允许的。
而在 Chrome 的 PNA 原则中,则会有一个新的请求头和返回体字段:
在请求头中会有 Access-Control-Request-Private-Network: true 这个字段,
在返回头中会有 Access-Control-Allow-Private-Network: true 这个字段。
PNA 的预检机制会存在在发向私域网的每一个请求中,无论这个请求的方法和模式是什么。这是因为所有的私域网请求最终都有可能会被用来进行 CSRF 攻击。
但是如果是通过公网向私网 IP 发送一个请求,预检机制在非跨域的情况下同样生效。这不像正常只针对跨域的情况生效。预检对同源的请求防止的是 DNS Rebind 攻击。所以如果私网的 IP 最近有变化,而浏览器缓存还保存的是原来请求的 IP, 就会被判定为跨域。 这个更改是为了防止 DNS Rebinding 攻击。
DNS Rebinding
下面我们来详细地介绍一下什么是 DNS Rebinding 攻击。首先我们要知道什么是同源策略,同源策略是指用户访问的资源和本地网站的 域名、IP、端口需要保持一致,否则会被判断为跨域。同源策略虽然对于网络攻击有一定的帮助,但是它防止不了 DNS Rebinding,因为这种攻击是从 IP 这个角度来进行的。
我们来试想下面的这样一个场景,你在街边,突然没流量了,你随便找了一个免费 wifi 连上,然后想要访问公司的内网去给客户打款,结果款打成功了,却打到了另外一个账户上,这是怎么回事呢?
上面的这个场景就是 DNS Rebinding 攻击的一个常见场景,原理是这样的,由于你用了一个攻击者的公网,此攻击者会在这个公网上设置一个和你们内网相同域名的域名,并将这个域名绑定到一个钓鱼 IP 上。当你访问这个域名时,会先访问攻击者的 Ip,请求到恶意脚本。而攻击者会将 DNS 的 TTL也就是缓存时间设置地非常短,在 1s 之内。这时如果用户再次向同样的域名发起请求,DNS 的缓存就会过期,随后重新进行 DNS 解析。在重新进行 DNS 解析的时候,攻击者服务器会将当前的域名重新绑定到原定访问的 IP 上,也就是公司正常的内网IP。那么接下来受害者电脑上向内网服务器上发送的任意一个请求就都在攻击者的监视下了。
从上面可以看出,传统的 CORS 标准只能监听到域名这一层,而 DNS Rebinding 却可以直接攻击到 IP 这一层,因此 Chrome 认为在 PNA 中,IP 的改变同样应该被当作跨域去检测。
如何预检请求
首先介绍一下请求的模式。一个请求是有多种模式的,这些模式决定了跨域请求是否能得到有效的响应,以及响应的哪些属性是可读的。
请求的模式
same-origin— 如果使用此模式向另外一个源发送请求,显而易见,结果会是一个错误。你可以设置该模式以确保请求总是向当前的源发起的。no-cors— 保证请求对应的 method 只有HEAD,GET或POST方法,并且请求的 headers 只能有简单请求头 (simple headers)。如果 ServiceWorker 劫持了此类请求,除了 simple header 之外,不能添加或修改其他 header。另外 JavaScript 不会读取Response的任何属性。这样将会确保 ServiceWorker 不会影响 Web 语义 (semantics of the Web),同时保证了在跨域时不会发生安全和隐私泄露的问题。cors— 允许跨域请求,例如访问第三方供应商提供的各种 API。预期将会遵守 CORS protocol 。仅有有限部分的头部暴露在Response,但是 body 部分是可读的。navigate— 表示这是一个浏览器的页面切换请求 (request)。 navigate 请求仅在浏览器切换页面时创建,该请求应该返回 HTML。
在这里和 PNA 有关的我们只讨论 cors 和 no-cors 模式中的配置。
no-cors 模式
如果在 https://foo.example/index.html 这个页面中嵌入了 <img src="https://bar.example/cat.gif" alt="dancing cat"/> 并且 bar.example 是一个私域网 IP。
Chrome 首先会发送一个预检请求
HTTP/1.1 OPTIONS /cat.gif
Origin: https://foo.example
Access-Control-Request-Private-Network: true
服务器会针对这个请求返回
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Private-Network: true
预检完成后,Chrome 会发送真正的请求
HTTP/1.1 GET /cat.gif
...
之后服务器就会正常返回了。
CORS 模式
HTTP/1.1 OPTIONS /delete-everything
Origin: https://foo.example
Access-Control-Request-Method: PUT
Access-Control-Request-Credentials: true
Access-Control-Request-Private-Network: true
预检返回
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Credentials: true
Access-Control-Allow-Private-Network: true
然后 Chrome 会发送一个真正的请求
HTTP/1.1 PUT /delete-everything
Origin: https://foo.example
服务器会返回
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://foo.example
解决 PNA 问题
由上方的解析我们可以得出,PNA 不止针对跨域的情况,只要从公网发往私网的请求,就会有这种检查。那么上面我们的问题其实就是在缓存中的请求是 IPa,但是如果最近这个域名的 IP 被更换掉了,或者使用 VPN 访问,就会认为当前的预检不合格,产生 CORS 错误。此时,清除掉缓存,重新从服务器拿请求就好了。
但是如果是真正的跨域问题,是需要使用上方的配置去进行修改的,即在请求头加上
Access-Control-Request-Private-Network: true
同时在机器的配置上加上
Access-Control-Allow-Origin:true
而上面的 PNA 问题其实看起来更像是一个 bug,因为即使是变更了 IP,也应该确保当前的网络请求是否有可能会触发 DNS Rebinding 的风险来判断。
以上就是我对于 Chrome 102 PNA 这个问题的思考和探索,也欢迎大家有相同的问题发现了更好的解决方案来一起交流。
参考文献: