一个客户域名被移出白名单,注册中心也明确拒绝了它。可如果之后注册中心恰好挂了,代理手里那份24小时的兜底缓存,会不会把这个已经被删掉的域名重新放出来?

这不是假设。在GoodBarber做多租户CORS缓存时,团队发现"注册中心答不出来"和"注册中心明确说不"是两件完全不同的事,而它们对缓存的要求正好相反。

打开网易新闻 查看精彩图片

两种沉默,两种命运

域名注册中心有两种失效方式。一种是它干脆不响应,另一种是它明确回答:这个客户的域名已经不再被允许。

两种情况都会让代理手上没有一份可用的最新列表。但只有第一种,才有理由去复用一份旧列表。

这个区别决定了整个缓存的设计。有效结果保留15分钟,兜底副本保留24小时。而一旦收到权威的拒绝,那份24小时的兜底副本会被直接抹掉——这样下一次注册中心故障,也没法把一个已经被移除的域名重新带回来。

每个客户一个域名

GoodBarber上每个客户应用的网页版都有自己的域名,要么是平台子域名,要么是自定义域名。团队正在试点一个代理,让挂件能调用第三方API,同时把私钥留在服务端。

这个代理的CORS策略必须贴合这种域名模型,哪怕服务本身还只是个小范围试点。

最早那份静态白名单,管五个试点应用还凑合。但它跟不上平台上成千上万个域名,客户随时在改。而如果放开成任意来源,又等于把本来想设的限制全废掉。所以必须按租户去解析这份列表。

预检请求不知道是谁在调用

真正的API调用会带一个短期令牌,令牌里写着租户是谁。但预检请求来得更早,而且不带这个令牌——CORS预检请求本身就不携带凭证。

预检里能拿到的只有来源、拟用的方法和一串请求头名称。令牌没法为这次查询提供租户身份。

请求路径可以。路径里有一个不透明的连接器句柄,属于某个租户。先解析句柄,再解析该租户的域名。因为这一步发生在令牌校验之前,查询还必须能容忍来自任意调用方的请求。

来源,必须和浏览器发的一模一样

注册中心返回的是一个域名,而浏览器发来的是一个来源。两者做的是字符串相等比较,所以列表必须按浏览器会写的方式构建。

  • 协议默认端口要省略:HTTPS省略:443,HTTP省略:80;非默认端口保留。
  • 自定义域名会产出两个来源,带www和不带www各一个,因为客户两种都会发布。平台自己的子域名只产出一个。
  • 国际化域名要转成punycode,因为浏览器发的就是这个。
  • 响应只带回那一个匹配的来源,并附上Vary: Origin。绝不用星号,而Origin: null永远不匹配。

一次未命中,需要知道原因

主缓存条目过期后,注册中心查询可能给出三种不同结果。故障路径借用了stale-if-error的思路:注册中心不可用时,应用可以继续使用此前已被接受的来源。把这个兜底决定缓存60秒,能减少重复查询;但并发未命中同时发生时,它本身并不保证一个全局的请求速率上限。

明确移除的含义则完全不同。如果保留它的旧列表,之后一次注册中心故障,就可能从那份24小时副本里把已撤销的来源恢复出来。删掉副本,拒绝才能在故障中同样存活。

预认证路径是有边界的。在任何I/O之前,先检查句柄的形状,并做哈希用于缓存键。未知句柄按未知缓存60秒。如果共享缓存不可用,一个存活5秒、最多4096条的进程内缓存,用来限制重复的数据库操作。

预检路径返回204。如果查询和它被允许的兜底都没能给出一个被许可的来源,响应就不带CORS头。这样依赖错误不会变成500,浏览器在一次不成功的预检之后也无法继续跨域调用。

把缓存当成输入来对待

缓存是共享基础设施,所以从里面读出来的东西,要像任何跨越边界的数据一样被校验。值是JSON,绝不用pickle对象。每一份读回的列表都要逐项对照当初写入时的同一套规则检查。一个存在但没通过检查的值会被忽略,并作为篡改信号记录下来。

评审在这里发现了一处不一致。归一化接受了一个含下划线的主机名,但缓存校验用了更严的规则,把它拒了。这个值会被反复写入、丢弃、再取回,让两个缓存对该租户都失效。写入和读取用同一套规则,分歧就消失了。

CORS不是那把锁

这些响应管的是浏览器访问。像curl这样的客户端并不执行它们,所以真正的请求仍然要过令牌校验和连接器的权限检查。使用一份旧的CORS列表,这些检查依然在;它改变的只是哪些浏览器来源可以读取响应。

这个功能和它的加固在同一天落地,相隔6分钟:第二个提交就是对第一个的对抗性评审。模块有48个测试,覆盖归一化、三种结果、预认证路径的边界,以及额外加上的少数几个静态来源。

要验证那个关键区别,可以这样做:先缓存一个被允许的域名,让注册中心明确移除它,再把注册中心变成不可用。这个域名应当保持缺席。只测故障场景,会漏掉缓存把一个已经收到的决定又反转回来的情况。