线上故障:WAF 拦截 CORS 预检请求

267 阅读5分钟

背景

在今年 7 月的用户增长活动中,由于大规模流量访问活动落地页,不仅使活动 api 响应缓慢,点单小程序整体功能也受到影响。活动结束后,为了减少活动 api 对小程序点单的影响,toC 端业务组开展小程序稳定性专项治理。一期工作如下:

  1. 新开一个域,命名为 h5api,专门用来承接活动落地页的流量。
  2. 活动落地页(h5 网页)实现前后端域名分离。
  3. 活动落地页开启 CDN 加速并回源到 OSS。

故障时的表现

前端的表现:

用户在活动落地页提交答案时,控制台有如下报错:

线上环境的 CORS 预检请求的响应头没有任何字段,导致浏览器不发送随后的正式请求,控制台出现跨域报错。

网关的表现:

答题接口的流量大概有 99% 被 waf 拦截。

后端服务的表现:

答题接口几乎没有流量。

总结与思考

恢复故障耗时 90 分钟,从故障复盘会得知:

  1. 网络安全人员:不知道浏览器发送正式的跨域请求前会先发送预检请求。
  2. 前端开发人员:不知道 waf 的一条拦截规则是,拦截未携带 authorization 标头的答题接口(/xxx/xxx/fcfs)。预检请求的请求方法是 OPTIONS,它未携带 authorization 请求头,waf 将其拦截,浏览器收到的响应头不包含任何字段,则不发起正式请求。

本项目是跨团队协作项目,涉及的职能有:前端、后端、测试、运维和网络安全,回顾从项目启动到结束全过程:

  1. x 月 x 日开项目立项会,会议文档中试图罗列所有的改动点,包含前端代码层面的改动、后端服务改动、ingress 配置改动和上线切换。
  2. 6 天后前端开技术方案评审会议,技术方案文档中试图罗列域名解析如何改动,ingress 如何改动。

这两次会议的问题是,会议主持人试图猜测协作方需要做哪些工作,以自己的猜测安排协作方的工作,实际上没有做到把控会议文档中的全部内容,这导致一些协作方沦为执行者的角色,同时在项目过程中没人察觉文档中的遗漏项。

从本项目和引发的故障中总结经验,一个正向的项目方案要经历的步骤是:

  1. 项目立项:说明项目背景和目标、拉齐协作方、确认上下游关系和各协作方负责人。
  2. 方案评审:默认每一个职能都需要写技术方案文档,如果评估后不需要单独的技术方案文档则说明原因。每个职能确认技术方案时,邀请下游协作方入会,向下游说明自己为它提供的输入。从网络分层模型可知,本项目的上下游关系:前端 -> 网络安全 -> 运维 -> 后端。

故障后改进:

  1. 活动开始前组织活动会议群,拉相关方入会,相关方作为活动用户体验活动,遇到问题时在群里反馈,确认活动数据无异常后散会。该措施用来解决出现故障时拉不齐人员的问题。
  2. 优化项目立项流程,前文已提到。

CORS 的知识点

CORS(Cross-Origin Resource Sharing,跨源资源共享)是一种安全机制,它允许或限制网页从另一个源访问资源。当一个网页尝试从与其不同的源请求资源时,就会涉及到CORS。两个源相同则说明它们的域名、协议和端口都分别相同,否则不同源。前文提到活动落地页实现前后端域名分离,指的是实现前端资源和后端服务不同源。

简单请求和复杂请求

请求同时满足以下条件,则被认为是简单请求:

  1. 使用GET、HEAD或POST方法。
  2. HTTP头部信息不超出以下几种字段:AcceptAccept-LanguageContent-LanguageContent-Type(限于三个值:application/x-www-form-urlencodedmultipart/form-datatext/plain)。
  3. 请求中的任何XMLHttpRequestUpload对象均没有注册任何事件监听器;XMLHttpRequest对象没有使用withCredentials属性。
  4. 请求中没有使用 ReadableStream 对象。

不是简单请求则是复杂请求。

简单请求发送和响应流程

浏览器直接发送简单请求到服务器,如果服务器返回的响应头 Access-Control-Allow-Origin 能匹配当前网页所在的源,那么将响应的内容提供给前端 JS 代码,否则阻止前端代码访问响应内容。

复杂请求发送和响应流程

预检请求

浏览器发送预检请求

如果请求不是简单请求,浏览器会先发送一个预检请求(OPTIONS方法),询问目标服务器是否允许跨域请求。预检请求头需要包含下列字段:

  • Access-Control-Request-Method 头部:说明正式请求将使用的方法
  • Access-Control-Request-Headers 头部:列出正式请求将要发送的自定义头部
  • Origin 头部:发送请求的源。

服务器响应预检请求

服务器收到预检请求后判断是否允许接下来的正式请求,如果是,则在响应中包含下列字段:

  • Access-Control-Allow-Origin 头部:允许的正式请求的源
  • Access-Control-Allow-Methods 头部:允许的正式请求的方法
  • Access-Control-Allow-Headers 头部:允许的正式请求的头部

正式请求

浏览器发送正式请求

浏览器收到预检请求的响应后,检查服务器是否允许跨域访问,是则继续发送正式请求。

服务器响应正式请求

服务器处理正式请求,并在响应中包含 Access-Control-Allow-Origin。

浏览器处理响应

浏览器检查响应头 Access-Control-Allow-Origin 是否能匹配当前网页的源,是则将响应的内容提供给前端 JS 代码,否则阻止前端代码访问响应内容。