NekoWheel 猫轮 —— 一起来愉快的造轮子吧!

NekoWheel 猫轮 —— 一起来愉快的造轮子吧!

编程那点事 随便写写 Go 6382 字 / 14 分钟

NekoWheel 想法的诞生

之前在博客中提到过,自己这段时间以来一直都挺丧的。知道每天效率都很低,但是又没啥干劲;有时又会因为一个小问题折腾一整天,结果发现又绕回到了原点。 因此想着去做些什么让自己能够跳出这个循环,至少能让我有兴趣有动力去写一些感兴趣的东西,但同时又能接触些新玩意儿,增长些经验。

在朋友圈经常看到以前的同学在发自己的匿名问题箱,仔细想了下这东西好像也不难写,不如我花点时间简单也对着做一个提问箱出来?其实就是个很简单的留言板嘛。 在 QQ 上和 @Moesang 提到了这个想法,他表示提问箱挺火的,可以整一个玩玩。(其实 @Moesang 很早以前就开始玩 popi 提问箱了) 想到我手里有个 n3ko.co 的域名,因此就给这个提问箱取名为 NekoBox。 现在 NekoBox 已经上线了,欢迎大家体验~ https://box.n3ko.co/

日常和 @Moesang 的聊天中,我们会聊到市面上的一些软件或应用。这些软件或应用可能会有一些功能上的小瑕疵或者小缺陷,无法满足个人的需求;或者就是一家独大处于垄断地位,让人别无他选。这时我总会开玩笑说要自己整个 NekoXXX 出来。 比如之前 @Moesang 提到蓝莲花战队开源的 XSS 接收平台不再维护了,然后我就打趣地说要不整个 NekoXSS。 聊到静态博客的留言,一般都会调用第三方的评论插件,比如 disqus。然后就想着能否整个 NekoDiscuss 也提供第三方评论留言服务。 后面服务多了,可能账号需要统一管理,那要不再整个 NekoCAS 做用户统一登录?(这个已经成真了,我最近就在写 NekoCAS)

上面的东西看起来都是在重复造轮子,那不如给这个项目名为 NekoWheel 吧! 就这样,NekoWheel 诞生了。我还给她画了个不是那么好看的 Logo:

其实还是源于我不切实际的想法,想着能不能自己独立实现日常我们在网上所用到的一些应用或服务呢?

同时,我感觉 NekoWheel 也是一个实验性的挑战——我想看看,仅凭着我们个人的力量以及所能接触到的资源,去做这些东西,我们能够走多深,走多远。 和那些产品级的项目比起来,并没有百台机器做负载均衡,只有几台个人用爱发电的小鸡;没有过多的研究与讨论,只是想把心中的想法赶快实现;不清楚有什么约定俗成的规矩或最佳实践,只是凭着感觉走。

这其中可能会有很多不规范不成熟的地方,但我认为既然做了,总能从中收获感悟些东西不是吗?

reCaptcha 与 HTTP 参数污染

reCaptcha 的介绍与使用方法

好了,关于情怀啥的就扯这么多。下面开始聊点别的。 首先是 NekoBox 匿名问答箱,这是使用 Go + beego 编写的,beego 的那一套 MVC 写起来还是很舒服的,虽然刚开始因为自己没有仔细看文档踩了很多坑 QAQ。 主要的代码逻辑其实一点都不复杂,其实就是个带登录注册的留言板,图片上传对接的是 @Moesang 的图床。 这里想聊的是谷歌的验证码服务:reCaptcha。 在 NekoBox 快要上线前,@Moesang 跟我说怕有人恶意刷问题,所以加个验证会稳妥些。验证选择的是 Google 的 reCaptcha。其最初还只是单纯的输入验证码——将古旧书籍中难以被 OCR 识别的单词作为验证码给人类来输入,既达到了验证是人还是机器的目的,同时也对古籍的数字化工作作出了贡献。可谓妙哉。

而 reCaptcha v2 加入了Invisible reCAPTCHA,即静默验证。用户在点击提交按钮的时候,reCAPTCHA 就通过用户在浏览器上的鼠标操作轨迹,用户的 IP,浏览行为等多项因素,来判定对面是人还是机器。这一切的验证通常不会被用户所察觉。除非 reCAPTCHA 认为对面是机器时,它才会在页面上弹出验证码,让访问者选择斑马线、红绿灯、小汽车之类的图片以便进行二次确认。这些斑马线、红绿灯的图片样本其实就来源于谷歌的街景地图,跟上文提到的一样,也是在借用户的力量更好的完善谷歌街景的信息。

关于 reCaptcha 的调用,其实遵循谷歌的开发者文档即可。 我们会拿到两个 key,一个site key一个secret key。我们在网页的前端插入调用 reCAPTCHA 的 script标签,并填入site key来告诉 Google 我们是哪个站点。同时还需要将 reCAPTCHA 与页面表单的提交按钮绑定在一起:

<button type="submit" class="uk-button uk-button-primary g-recaptcha" data-sitekey="your_site_key_here" data-callback="onSubmit">登录</button>

其中data-callback指定的是验证通过后的回调函数,这里是提交表单的操作。然后你就会神奇的发现表单的提交内容里多了个g-recaptcha-response的字段。

后端获取到g-recaptcha-response这个字段,用这个字段以及自己的secret key向 reCAPTCHA 的验证接口发送请求来验证这个 response 是否真实有效。返回有效则表明验证通过。

HTTP 参数污染

乍一看这个流程其实挺完善的,但 reCAPTCHA 曾在 2018 年被人发现存在 HTTP 参数污染漏洞,可以使得攻击者绕过 reCAPTCHA 的验证。 可以参考安全客的这篇文章:https://www.anquanke.com/post/id/146570

挺有意思的。问题出在g-recaptcha-response这个参数。这是用户的浏览器前端发送给后端的,因此用户可控。 在 reCAPTCHA 的官方文档中,后端验证的接口是这样:

URL: https://www.google.com/recaptcha/api/siteverify METHOD: POST
ParameterDescription
secretRequiredThe shared key between your site and reCAPTCHA.
responseRequiredThe user response token provided by the reCAPTCHA client-side integration on your site.
remoteipOptionalThe user’s IP address.

我们的后端是需要将secret key以及用户输入的response POST 发送给验证的接口。 这时,如果我们的后端发送代码是类似这么写的:

sendData = "response="+CaptchaResponse+"&secret="+Secret

直接将 Payload 使用字符串拼接,就可能会出现问题。如果攻击者在可控的CaptchaResponse后面再额外加上一段&secret=...,那么最终拼接出来的字符串将会变成:

sendData = "response=xxxxxxxxxxx&secret=attacker_evil_secret&secret=real_secret_key"

可以看到攻击者可以再加入一个secret参数到 Payload 里,而 reCAPTCHA 验证服务在解析 Payload 时,遇到重复的变量是会默认取第一个的值的,也就是取得的是攻击者的secret

而 reCAPTCHA 为了方便开发者的程序在自动化测试的环境下能够跑通,提供了一对特殊的 key:

Site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
Secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe

使用这一对 key 将禁用 reCAPTCHA,使得所有验证都返回通过。因此可以构造恶意的g-recaptcha-response来绕过 reCAPTCHA。 需要注意的是,这里的利用是有要求的,后端 Payload 拼接代码必须形如:

sendData = "response="+CaptchaResponse+"&secret="+Secret

response在前,secret在后的形式。若secret在前,则 reCAPTCHA 将会获取第一个正确的secret key而忽略掉攻击者的secret key。 谷歌对此给出的修复方式也是简单粗暴,当出现两个重名的参数时,直接返回报错。 而对于开发者而言,为了避免 HTTP 参数污染,在输入 POST 表单参数的时候,应采用键值对的形式插入,而非直接的字符串拼接。或者给字符串拼接时外部传进来的值,做一下 URL 编码。

Go vs PHP vs Python

这里额外再补充一下 Go、PHP、Python 这三种语言对 HTTP GET 参数解析的区别。我分别写了三个 Demo 来对比了一下。

  • Go Go 的 Web 框架其实是对 Go 原生 http 包的封装。在原生包中的request.URL.Query()是调用了parseQuery()解析 URI 中的 GET 参数,然后将参数值放入一个切片中。Gin 框架的GetQuery()只是取了这个切片的第一个元素。因此 Go 遇到重复参数是优先取最前面的一个。

  • PHP PHP 的 GET 参数传递相比其它语言就比较魔幻了。首先对于同名参数,PHP 优先取最后面一个。并且对于传入数组型的参数,PHP 需要在参数名后加上[],解析出来的就是array类型。因此对于很多写得不是很完善的 PHP 站,顺手将 GET 参数名后面加个[]改成数组,就会爆 500。(万一给你爆出个 Laravel 的调试页面嘻嘻

  • Python (Flask) 因为对于 Python Web 不是很熟,所以只用 Flask 来测试了。Flask 默认也是以 Dict 的形式存储所有参数。request.args.get()是取 Dict 中的第一个符合条件的元素。因此 Flask 在遇到重复参数时,也是优先取最前面一个。

综上,我们能看出来不同语言对于 GET 参数的解析顺序存在区别。因此,也就不能一刀切的说把用户传入的参数拼接在字符串的最后面就能一劳永逸。万一你遇到个后端是 PHP 写的不就完了。还是老老实实 Set key-value 吧。

NekoCAS

写完 NekoBox 后,我寻思之后可能真的会有统一登录的需要,况且协会之前也计划过将现有的服务做个统一登录。说干就干,NekoCAS,冲! 这里的 CAS,其实默认指的是跨域 SSO(单点登录),即不同的服务位于不同的域上。看了些文章,同时看了一些网站的第三方登录实现。 这里推荐看一下 CAS 官方文档上对 CAS 协议的概述:https://apereo.github.io/cas/6.1.x/protocol/CAS-Protocol-Specification.html

首先要明确 CAS 的几个名词:

  • TGT (Ticket Grangting Ticket) 证明用户已经在该服务上登录的凭证。在 NekoCAS 中我是在用户确认授权后,在service_auth表里存储一条用户+服务的信息。这里面也包含有用户对于该服务的唯一标识。

  • TGC (Ticket Granting Cookie) CAS Session 返回的 Cookie,用于保持用户在 CAS 的登录状态。

  • ST (Service Ticket) 单次登录某服务后,生成的一次性凭证。跳转到服务的回调页面后,服务后端会带着 ST 请求 CAS 进行验证。我是将 ST 直接以 String 类型存在 Redis 中了,内容为服务的 ID 与用户的 ID。存 Redis 是因为这样可以很方便的设置过期时间。(由于 Redis 没有事务,所以查找 ST 与删除 ST 的操作是分开的,但是问题不大。)

下面就可以来看看 CAS 的流程了~ 理解起来还是很简单的:

  1. 用户访问 服务A,302 跳转到 CAS。(GET 参数包含 服务A 回调地址)
  2. 用户在 CAS 登录,登录成功后带着 Service Ticket 跳转到 服务A 的回调地址。(同时 CAS 的 Session 里也保存了用户 CAS 的登录状态了)
  3. 服务A 后端使用 Service Ticket 请求 CAS 验证是否正确。
  4. CAS 后端返回 Service Ticket 正确,并且提供用户的唯一标识。(之后 服务A 根据唯一标识就知道是自己系统里的哪个用户登录了,顺便再设置一下自己的 Session) 以上便结束了 服务A 的登录。
  5. 用户访问 服务B,302 跳转到 CAS。(GET 参数包含 服务B 回调地址)
  6. 因为之前已在 CAS 登录过,直接带着 Service Ticket 跳转到 服务B 的回调地址。(连跳两下挺快的,用户可能都没察觉到)
  7. 服务B 后端使用 Service Ticket 请求 CAS 验证是否正确。
  8. 同上,CAS 后端返回 Service Ticket 正确,并且提供用户的唯一标识。

如果感觉我说的不够明白,可以配合下图理解一下:

NekoCAS 主要有如下路由:

  • /register 新用户注册
  • /login 【CAS 协议】登录
  • /authorize 服务授权
  • /profile 个人信息修改
  • /validate【CAS 协议】服务验证接口

这里选择几个有意思的来聊聊:

/login

登录这里,一般来说用户是通过第三方的服务,跳转到 CAS 登录来的。往往会带上service这个参数。 在 CAS 协议的规定里,是这么描述/login路由下的service参数的。

the URL of the application the client is trying to access.

这里强调了service参数需要是一个 URL,因此我们往往把服务的回调 URL 放在这里。协议文档下面提醒了需要对 URL 进行验证。我这里是直接做了个域名的白名单,防止任意跳转到恶意页面的问题。 而有时,用户可能是直接访问的/login页面,这种不带service参数的情况,我是在登录后跳转到 CAS 的用户信息页面。对应的实现是在/login路由前面加了个loginPreCheck的中间件来解析、检查 URL 中的service。若不存在service,则设置serviceID为 0,登录后根据这个 ID 直接跳回用户信息页。

serviceID := c.GetInt("serviceID")
if serviceID == 0 {
	// login cas
	c.Redirect(302, "/")
} else {
	......
}

/validate

这里一般是服务才会请求的接口。服务会用该接口来验证 Service Ticket 是否有效并获取用户信息。 CAS 协议规定必须传入的两个参数serviceticket,前者用来表明请求者是什么服务,后者则是Service Ticket。 但是相比上面登录的service参数,这里对于service的介绍却有所不同:

the identifier of the service for which the ticket was issued. As a HTTP request parameter, the service value MUST be URL-encoded as described in Section 2.2 of RFC 1738

可以看到这里其实并没有指出service一定是服务的 URL,只是说是一个“标识符”,能认出是哪个服务就行。因此我这里的service就不再让传入服务的 URL,而是改成让传入服务自己的一个 Secret。这是 NekoCAS 给每个服务都会分配的一个私有密钥,服务在service中填入该密钥来表名自己的身份。这样做也避免了无关用户的查询。

但是要注意的是,规定的是Service Ticket只能使用一次!不管查询是否成功,Service Ticket使用一次后都会作废。因此/validate从方法入口一开始就要去 Redis 中查找Service Ticket。不管后面的 Secret 对不对,先找到Service Ticket再说。找到之后反手删了该Service Ticket,然后再开始判断 Secret。若正确,则返回用户信息。

/authorize

这在 CAS 协议中是没有规定的,是服务授权的接口。 在第一次使用 CAS 登录某服务后,会跳转到该页面请求用户按下一个按钮授权。用户授权后,则会生成并保存用户对于该服务的唯一标识符 Token,然后继续后面的步骤。之后用户当然也可以管理这些授权,取消授权啥的都可以。 在/login这个步骤中间新插入的一个功能,我大改了很多代码。这是因为用户 POST 请求登录登录后 GET 请求登录页面,这是两个方法,但是这两个方法最后都是服务授权、签发 Service Token、302 跳转这么一个流程。 因而我想尽可能的实现代码的复用,将二者相同的部分剥离出来,写成单独的方法。这其中难免会有些变量藕断丝连需要依赖,因此我改了挺久的。(还是太菜了

首要敌人——CSRF

像 NekoCAS 这种需要用户交互登录,点击授权按钮的 Web 应用。首先需要预防的就是 CSRF。 特别是/authorize授权接口,可能会有恶意的服务让用户 POST 请求/authorize从而给予授权。防 CSRF 其实没啥新鲜的,无非就是在表单里加个随机的 CSRF Token,然后 Session 里面存一份,对所有的 POST 请求前验一下表单参数里的 CSRF Token 是否对得上。 但苦于 Gin 没有成熟好用的 CSRF 中间件,无奈只能自己写一个。其实就是在全局路由最前面加个中间件,没 CSRF Token 就随机生成一个存 Session 里,顺便 Context 上下文也Set()一下,方面我们后面直接能从上下文中用GetString()轻松取;然后如果当前是 POST 请求,则检查表单参数,不对则Abort()掉。 在注册、登录、登出等位置加上 CSRF Token 我觉得已经绰绰有余。但是对于/authorize这个授权接口,我还是总感觉不放心。

于是我便去看了下 GitHub 第三方登录授权,这里以 Leetcode 接入 GitHub 登录为例。 用户在 GitHub 授权页面,点击绿色授权按钮后,会向https://github.com/login/oauth/authorize POST 提交表单,参数如下:

KeyValue
authorize1
authenticity_tokenVrJiBn9nC+6gKBWTfDTZ+J5w……B9659Yofip48ngWvTOzd9l6If6QTYL3YEOA==
client_idcc568d196569c732158c
redirect_urihttps://leetcode-cn.com/accounts/github/login/callback
scopeuser
authorize1

这里的client_id就是 Leetcode 在 GitHub 的 AppID,redirect_uri是回调地址。至于上面的authenticity_token,我在想它是否是对client_id以及redirect_uri所做的一个签名呢?

我又开了个其它应用的授权应用,将这里的client_idredirect_uri进行替换。结果发现居然授权成功了! 原来 GitHub 这里的authenticity_token也仅仅只是起到一个 CSRF Token 的作用,并没有对client_idredirect_uri做过多的校验。 嘛,虽然心里还是感觉这样还有点不妥,但既然 GitHub 都这么做,那我也学着吧。因此我在 NekoBox 的/authorize页面并没有对client_id redirect_uri是否经过修改再做判断。

以后要做的事呢?

以上就是 NekoWheel 到目前为止所做的东西。其实 NekoCAS 还有一些收尾的功能还没写,找个时间肝完,然后配上 CI 就能上线咯~ 实不相瞒,其实在写 CAS 的过程中,为了方便地实现表单验证,我又自己写了个表单验证库——govalid。 https://github.com/wuhan005/govalid

这里面就是各种反射 + 各种判断啦。对于我目前的需求来说,她是完完全全够用了。之后可能还会加入自定义检查函数的功能。如果有空的话可以单独写一篇文章来聊聊 写 govalid 的一些心得体会。我现在感觉学完 Go 的反射之后,就应该写个表单验证包来锻炼自己。

我也不知道 NekoWheel 今后会怎么样,不过目前她确实让我接触到了很多新东西。我也开始发现日常遇到的一些应用,让说原理都能说得出来,也理所应当地觉得实现起来很简单。但只有亲自写一写,才发现其实存在很多细节需要仔细斟酌推敲的。所以我认为这还是值得去做的。 接下来的话怕不是真的会去试试写静态博客第三方留言,或者 XSS 接收平台什么的?哈哈。前者的话,我感觉概率还蛮大的。

“嘛,喜欢的话就坚持吧!”