基于 Serverless 的 Valine 可能并没有那么香

Valine 是一款样式精美,部署简单的评论系统, 第一次接触便被它精美的样式,无服务端的特性给吸引了。它最大的特色是基于 LeanCloud 直接在前端进行数据库操作而无需服务端,极大的缩减了部署流程,仅需要在静态页引入 Valine SDK 即可。

👨‍💻‍ 初识 Valine

以下是 Valine 官网提供的快速部署脚本,其中 appIdappKey 是你在 LeanCloud 上创建应用后对应的应用密钥。也正是基于这对密钥,Valine 在内部调用了 LeanCloud SDK 进行数据的获取,最终将数据渲染在 #vcomments 这个 DOM 上。这便是 Valine 的大概原理。

<head>
  ..
  <script src='//unpkg.com/valine/dist/Valine.min.js'></script>
  ...
</head>
<body>
  ...
  <div id="vcomments"></div>
  <script>
    new Valine({
      el: '#vcomments',
      appId: 'Your appId',
      appKey: 'Your appKey'
    })
  </script>
</body>

有同学可能会有疑问了,appIdappKey 都直接写在前端了,那岂不是谁都可以修改数据了?这就需要牵扯到 LeanCloud 的数据安全问题了,官方专门写了篇文档《数据和安全》 来说明这个问题。简单的理解就是针对数据设置用户的读写权限,确保正确的人对数据有且仅有正确的权限来保证数据的安全。

乍听一下,保证用户数据只读的话,感觉还是挺安全的。可事实真的如此么,让我们继续来看看。

🙅‍♂️ Valine 的问题

📖 阅读统计篡改

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

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

可以看到该页面的访问统计被设置成了 -100000 了。这个问题唯一值得庆幸的是 time 字段的值是 Number 类型的,其它的值都无法插入。如果是字符串类型的话就是一个 XSS 漏洞了。

该问题有一个解决办法,就是不使用次数累加的存储方式。更改为每次访问都存储一条只读的访问记录,读取的时候使用 count() 方法进行统计。这样所有数据都是只读的,就不存在篡改的问题了。这种解决方案唯一的问题就是数据量会比较大,对查询会造成一定压力。当然如果是在基于原数据不变的情况下,只能是增加一层服务端来做修改权限的隔离了。

🧯 XSS 安全

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

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

那有没有根本的解决办法?其实是有的。针对存储型的 XSS 攻击,我们可以使用转义编码进行解决。只要效仿早前 BBCode 的做法,提交到数据库的是 Markdown 内容。前端读取到内容对所有 HTML 进行编码后再进行 Markdown 转换后展示。

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

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

不过这个方法存在一个问题,那就是对老数据存在不兼容。因为这相当于修改了存储和展示的规则,而之前一直存储的都是 HTML 内容,修复后之前的数据将无法展示 HTML 样式。而为了能在存储的还是 HTML 情况下规避 XSS 安全问题,唯一的办法就是增加服务端中间层。存储阶段增加一道阀门,将转义阶段提前至存储阶段,保证新老数据的通用。

🖼 隐私泄露

说完了存储的问题,我们再来看看读取的问题。攻击者除了可以直达存储,也可以直达读取,当一个数据库的字段开放了读取权限后,相当于该字段的内容对攻击者是透明的。

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

为了规避这个问题,Valine 作者增加了 recordIP 配置用来设置是否允许记录用户 IP。由于是 Serverless,目前能想到的也只是不存储的方式解决了。不过该配置项会存在一个问题,就是该配置项的配置权在网站,隐私的问题是评论者遇到的,也就是说评论者是无权管理自己的隐私的。

除了这个矛盾点之外,还有就是邮箱的问题。邮箱本质上只需要返回 md5 用来获取 Gravatar 头像即可。但是由于无服务端的限制,只能返回原始内容由前端计算。而邮箱我们又需要获取到原始值,方便做评论回复邮件通知功能。所以我们也不能不存储,或者存储 md5 后的值。

该问题的解决方案只能是增加一层服务端,通过服务端过滤敏感信息解决这个问题。

🎊 Waline!

基于以上原因,我们发现只有增加一层服务端中间层才能很好的解决 Valine 的安全问题,所以 Waline 横空出世了!Waline 与 Valine 最大的不同就是增加了服务端中间层,解决 Valine 暴露出来的安全问题。同时基于服务端的特性,提供了邮件通知微信通知评论后台管理、LeanCloud, MySQL, MongoDB, SQLite, PostgreSQL 多存储服务支持等诸多特性。不仅如此,Waline 默认使用 Vercel 部署,实现完全免费部署!

Waline 最初的目标仅仅是为 Valine 增加上服务端中间层。但是由于作者不知为何从 1.4.0 版本开始只推送编译后的文件到 Github 仓库中,源文件停止更新。导致我只能连带前端也实现一遍。当然前端的很多代码和逻辑为了和 Valine 的配置保持一致都有参考 Valine,甚至在名字上,我也是从 Valine 上衍生的,让大家能明白这个项目是 Valine 的衍生版。

📔 后记

Serverless 的概念火了非常多年,但技术没有银弹,我们在看到它的优点的同时,也要正视它所带来的问题。而 Serverless 自己可能也意识到了这个问题,从早期的无服务端慢慢转向了无服务器,更偏向 BaaS 了。不过由于 Valine 没有开放源代码,所以上面说的一些问题和解决方法只能等待作者自己发现这件事了。