设计目标

大约 6 分钟...

Valine

Valine 是一款样式精美,操作简单,部署高效的评论系统。第一次接触便被它精美的样式,无服务端的特性给吸引了。它不含服务端,前端直接和 LeanCloud 存储服务交互,真是太酷了!但是随着深入了解,我发现它存在着一些问题。

Valine 的问题

源码不开放

作者不知为何从 1.4.0 版本开始只推送编译后的文件到 GitHub 仓库中,源文件停止更新。可能是被开源伤了心吧。对于我这种想增加或者修改功能的用户来说,这个问题就有点难受了。

XSS 安全

从很早的版本开始就有用户报告了 Valine 的 XSS 问题,社区也在使用各种方法在修复这些问题。包括增加验证码、前端 XSS 过滤等方式。不过后来作者才明白,前端的一切验证都只能防君子,所以又去除了验证码之类的限制。

现有的逻辑里,前端发布评论的时候会将 Markdown 转换成 HTML 然后走一下前端的一个 XSS 过滤方法最后提交到 LeanCloud 中。从 LeanCloud 中拿到数据之后因为是 HTML 直接插入进行显示即可。很明显,这个流程是存在问题的。只要直接提交的是 HTML 而且拿到 HTML 之后直接进行展示的话,XSS 从根本上是无法根除的。

根本的解决办法

针对存储型的 XSS 攻击,我们可以使用转义 HTML 编码一劳永逸的解决。只需效仿早前 BBCode 的做法,提交到数据库的是 Markdown 内容。前端读取到内容对所有 HTML 进行编码后再进行 Markdown 转换后展示。

function encodeForHTML(str) {
  return ('' + str)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&#x2F;');
}

由于 Serverless 攻击者是可以直达存储阶段,所以数据存储之前的一切防范是无效的,只能在读取展示过程处理。由于所有的 HTML 转义后无法解析,Markdown 相当于我们根据自定义的语法解析成 HTML,保证转换后的 HTML 没有被插入的机会。由于 Valine 没有开放源代码,所以无法进行 Pull Request。

由于上述办法会完全将用户限制在 Markdown 的范围内,目前 Waline 在客户端自 0.15.0 后引入了 DOMPurify 来阻止 XSS。除常规的 XSS 过滤外,针对性的:

  • 禁用 <form><input> 等表单控件
  • 禁用用户样式的添加
  • 禁用自动拖动属性与媒体的自动播放
  • 所有的外部链接均会被处理,以 noopener noreferrer 的形式在新窗口打开

隐私泄露

攻击者除了可以任意存储,也可以任意读取,数据库的字段开放读取权限后,该字段的内容对攻击者是完全透明的。

在评论数据中,IP 和邮箱两个字段包含了用户的敏感数据。灯大甚至专门写了一篇文章来批判该问题 《请马上停止使用 Valine.js 评论系统,除非它修复了用户隐私泄露问题》open in new window。甚至掘金社区在早期使用 LeanCloud 的时候也暴出过 泄露用户手机号open in new window 的安全问题。

为了规避这个问题,Valine 作者增加了 recordIP 配置用来设置是否允许记录用户 IP。由于无服务端,只能通过不存储的方式解决。

该配置项仍存在一个问题: 记录配置权在网站,评论者无权管理自己的隐私。

邮箱泄露是另一个重大隐私问题。在前端计算并上报用户邮箱的 md5 用来获取 Gravatar 头像是完全可行的。但是如果需要当评论被回复时发送邮件通知,就不可避免的要存储用户邮箱的原始值。这个问题理论上可以通过 RSA 加密来解决,私钥存储在 LeanCloud 的环境变量中,客户端同时上报邮箱 md5 和公钥加密后的邮箱,LeanCloud 在发送邮件通知时在云函数中通过环境中的私钥解密得到用户邮箱。但是考虑到前端 RSA 加密库的体积与性能,实际应用可行性很小。增加一层服务端,通过服务端过滤敏感信息是一个较优的做法。

阅读统计篡改

Valien 1.2.0 增加了文章阅读统计的功能,用户访问页面就会在后台 Counter 表中根据 url 记录访问次数。由于每次访问页面都需要更新数据,所以在权限上必须设置成可写,才能进行后续的字段更新。这样就造成了一个问题,实际上该条数据是可以被更新成任意值的。感兴趣的同学可以打开 https://valine.js.org/visitor.htmlopen in new window 官网页面后进入控制台输入以下代码试试。试完了记得把数改回去哈~

const counter = new AV.Query('Counter');
const resp = await counter.equalTo('url', '/visitor.html').find();
resp[0].set('time', -100000).save();
location.reload();

值得庆幸的是 time 字段的值是 Number 类型的,这样其它的值无法插入。如果 time 字段是字符串类型会产生新的 XSS 漏洞。该问题有一个可行解决办法,就是不使用次数累加的存储方式。更改为每次访问都存储一条只读的访问记录,读取的时候使用 count() 方法进行统计。这样所有数据都是只读的,解决了篡改的问题。这种解决方案也存在问题: 数据量比较大时对查询会造成一定压力。

如果需要保持原数据结构,只能增加一层服务端进行修改权限的隔离。

Waline 的诞生

基于以上原因,Waline 横空出世了。Waline 最初的目标仅仅是为 Valine 增加上服务端中间层,但是由于 Valine 的不开源所以只能连带前端也实现一遍。当然前端的很多代码和逻辑为了和 Valine 的配置保持一致都有参考 Valine。甚至在名字上,我也是从 Valine 上衍生的,让大家能明白这个项目是 Valine 的衍生版。

增加了服务端除了解决了上述的安全问题,之前 Valine 受限于无服务端的很多功能也可以实现了。包括但不限于邮件通知、垃圾评论过滤等。

友情提示:评论区仅作评论展示,如有问题咨询请去 Github Discussion 中提问。

你认为这篇文章怎么样?

  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.12.0