某一天,同事向我求助:我在 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 应用,我们有很多种方式可以进行调试。
- 使用 whistle proxy 模式进行远程调试,如注入 vConsole, 线上资源映射本地
- Android Webview 使用
chrome://inspect
模式进行有线调试 - 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 代理层由运维同学修改,这样灵活性也可以提升很多。