一次有趣的跨域问题排查
2023-03-13 17:06:28

某一天,同事向我求助:我在 UAT 环境下的一个页面报错了,用户无法访问,但是我本地正常,只有 Android App 里异常,基于敏锐的嗅觉,这会是一个有趣的问题,于是这个活儿,接了。

1.问题描述

首先简单介绍下网页的基础技术架构。

该网页由 React + Typescript 编写的一个单页面 H5 应用,放在域名 A 下,同时使用 Axios 作为请求基础库向非同域的一个服务 B 发送了资源访问。资源请求为 Get, 是一个简单请求。

该页面支持公司内部的一个 App (以下称 HomeApp) Webview 访问,同时也支持第三方浏览器访问。

为了方便,服务端设置了 Access-Control-Allow-Origin: * ,前端开启了 withCredentials: true, 会在访问跨域资源的时候带上 Cookie.

再来看下问题的描述:

  • 业务人员反馈开启了 VPN 使用HomeApp访问该页面,页面无法访问
  • 不开启 VPN 访问页面依然无法访问
  • 开启 VPN 用第三方浏览器打开页面正常
  • 跨域资源返回的响应码是 403

经初步排查,页面无法访问是因为出现了跨域资源错误。

2.排查流程

2.1简单分析

首先对问题做简单的分析,因为是跨域资源访问出错,本着“大胆猜想,小心求证”的原则,首先考虑是资源请求头设置不合理,然后是网关层做了一些拦截。

同时,伟人曾说过,如果你能重新问题,那么问题就已经解决了一半。所以我们首先要做的就是重现问题。

2.2被误导的方向

因为怀疑是后端的跨域配置设置有问题,所以一开始就朝着跨域失败的方向去了。同时因为业务提到在连接 VPN 的情况会有问题,而自己在本地开启了代理同样可以复现问题,因此下意识的认定是代理层的某些 cookie 共享失败导致无法跨域,但在要求业务关掉代理访问却依然存在问题。

后来简单求证后发现并非如此,因为在公司的服务网关层有一层流量拦截,allow_office_ip ,该配置限制了必须是公司的办公网络才可以访问该页面。

在联系公司的 SRE 同学将该配置禁用后发现,本地在第三方浏览器中访问正常,但是在 HomeApp 访问异常。

因此,必须要用 debug 模式详细的走遍流程了。

2.3真正的问题重现

对于常见 H5 应用,我们有很多种方式可以进行调试。

  1. 使用 whistle proxy 模式进行远程调试,如注入 vConsole, 线上资源映射本地
  2. Android Webview 使用 chrome://inspect 模式进行有线调试
  3. iOS 使用Safari浏览器打开 H5,可以使用开发者工具进行有线调试

因为我们的 HomeApp 使用的就是原生的 Webview,因此可以第 2 种方式进行调试。因为在第三方浏览器中表现正常,但是在 HomeApp 中表现异常,因此我们通过 chrome://inspect 复制出错的请求。

HomeApp 中跨域的请求如下

// HomeApp request
fetch("https://test.domain.com/sp-openapi/collection/barcode/app?shipment_id=SPXMY031824121872&token=91b124840148254e874d4a9476b5b8698c4122525281347bf95d7c3bf8481c40", {
  "headers": {
    "accept": "application/json, text/plain, */*",
    "x-csrftoken": "vOxigZ9KDrP0xEu6jagcTEgEITV28AlM"
  },
  "referrer": "https://domain.com/",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": null,
  "method": "GET",
  "mode": "cors",
  "credentials": "omit"
});

看起来并没有什么问题,我们再来看一下浏览器中的请求。

// Chrome request
fetch("https://test.domain.com/sp-openapi/collection/barcode/app?shipment_id=SPXMY031824121872&token=91b124840148254e874d4a9476b5b8698c4122525281347bf95d7c3bf8481c40", {
  "headers": {
    "accept": "application/json, text/plain, */*",
    "sec-ch-ua": "\"Chromium\";v=\"110\", \"Not A(Brand\";v=\"24\", \"Google Chrome\";v=\"110\"",
    "sec-ch-ua-mobile": "?1",
    "sec-ch-ua-platform": "\"Android\"",
    "sec-fetch-dest": "empty",
    "sec-fetch-mode": "cors",
    "sec-fetch-site": "same-site",
  },
  "referrer": "https://domain.com/",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": null,
  "method": "GET",
  "mode": "cors",
  "credentials": "omit"
});

然后我们做了以下尝试

  • HomeApp request 放到 Chrome 中执行,依然失败
  • Chrome request 放到 HomeApp 中执行,成功响应

那么看起来就是其中部分 Header 配置的问题了,通过比较两者的差异,我们发现通过 HomeApp 访问的资源会带上一个 x-csrftoken, 经过数次验证也发现确实是因为这个配置导致了跨域错误。

2.4CSRF-TOKEN

CSRF 攻击是什么

CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。

一个典型的CSRF攻击有着如下的流程:

  • 受害者登录a.com,并保留了登录凭证(Cookie)。
  • 攻击者引诱受害者访问了b.com。
  • b.com 向 a.com 发送了一个请求:a.com/act=xx。浏览器会默认携带a.com的Cookie。
  • a.com接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求。
  • a.com以受害者的名义执行了act=xx。
  • 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让a.com执行了自己定义的操作。

简而言之,如果你的服务的跨域设置配置不当,比如Access-Control-Allow-Origin: * ,同时前端开启了 withCredentials: true,那么一旦你的登录凭证被窃取,服务就是透明的了,这存在严重的安全漏洞。

CSRF-TOKEN 就是解决这类漏洞的一种实现方式,它的原理是:要求所有的用户请求都携带一个CSRF攻击者无法获取到的Token。服务器通过校验请求是否携带正确的Token,来把正常的请求和攻击的请求区分开,也可以防范CSRF的攻击

完整的流程应该是下面的样子:

chat-GPT 的回复

3.问题解决

那再回到我们的问题本身,前端同学使用 Axios 默认的配置, 因为开启了withCredentials: true, 所以也默认加上 csrf 的选项。

const options = {
  withCredentials: true,
  // `xsrfCookieName` is the name of the cookie to use as a value for xsrf token
  xsrfCookieName: 'csrftoken',
  // `xsrfHeaderName` is the name of the http header that carries the xsrf token value
  xsrfHeaderName: 'X-CSRFToken',
};
const request = createRequest(baseURL, options);

后端同学使用是公司的内部 Golang 框架,该框架集成了 go-csrf 的能力,默认是开启的,但是如果想要使用 csrf-token 的能力,需要对其进行配置。

var demoKey = []byte("secrect-key")
p := csrf.Protect(demoKey,
  csrf.MaxAge(csrfConf.MaxAge), csrf.Domain(csrfConf.Domain), csrf.Path(csrfConf.Path),
  csrf.HttpOnly(csrfConf.HttpOnly), csrf.Secure(csrfConf.Secure), csrf.SameSite(csrfConf.SameSite),
  csrf.RequestHeader(csrfConf.RequestHeader), csrf.FieldName(csrfConf.FieldName),
  csrf.ErrorHandler(csrfConf.ErrorHandler), csrf.CookieName(csrfConf.CookieName),
  csrf.TrustedOrigins(csrfConf.TrustedOrigins))(&cp)
p.ServeHTTP(&cp, req)

所以问题出现的原因是:前后端同学忽视了项目中的默认配置,对于这个 csrf-token 没有很好的约定,导致 csrf-token 校验失败。

想解决这个问题有两种方式。

3.1不使用 csrf-token 的校验

将 Axios 的默认配置修改为不传递 csrf-token ,那么就不会触发 csrf-token 的校验。但这种方式仅为临时解决,并不能解决本身的安全问题。

const options = {
  withCredentials: true
};
const request = createRequest(baseURL, options);

3.2开启 csrf-token 的检验,后端配合修改

前后端约定好 csrf-token 的取值传递,做好 csrf-token 的一致性校验,这种方式能最大程度的修复安全漏洞,推荐使用这种方式。

4.思考

这个问题出现的场景还是比较少见的,因为为了解决一个跨域问题,我们为了省事而要求后端开启Access-Control-Allow-Origin: *,这种方式是存在安全漏洞的,而且中间链路不透明,不利于我们的问题排查。

对于常见的跨域问题,如果资源的调用在同一个业务团队,推荐使用 前端BFF代理的方式进行中转,代理的逻辑前置有益于问题的排查(因为跨域问题只发生在浏览器,所以后端同学可能比较陌生)。

同时,如果必须要使用 Access-Control-Allow-Origin: *这种方式,也尽量在 Nginx 代理层由运维同学修改,这样灵活性也可以提升很多。

5.参考

Prev
2023-03-13 17:06:28
Next