欢迎新项目合作 加纳阿克拉
所有写作

网络安全 · 威胁建模 · 后端 · Cloudflare Workers

对联系表单进行威胁建模:在一个非常小的 API 上进行深度防御

联系表单看起来像网站上最无聊的功能。它也是一个公共端点,接受来自陌生人的文本并将其发送到数据库和收件箱。以下是我如何对这个网站上的联系表单进行威胁建模,逐层分析,以及它仍然存在哪些弱点。

作者:Festus Boamah · · 6 分钟阅读

这个网站上的联系表单大约有 40 行表单代码和一个 API 路由。很容易把它当作一个细节来对待。但是,从另一方面来看:它是一个 公共端点,开放给整个互联网,接受来自陌生人的文本并将其写入数据库和电子邮件收件箱。 这正是攻击者所寻找的功能类型,因为没有人监视它。

在我发布它之前,我坐下来对其进行了威胁建模。这篇文章介绍了这个过程:可能发生什么问题,我如何针对每个威胁进行构建,以及设计中仍然存在哪些弱点。

联系表单的数据流图:访客的浏览器将JSON发送到Cloudflare Worker,Worker应用六层(验证、蜜罐、速率限制、Turnstile、参数化SQL、安全电子邮件头)后,将消息存储在D1并发送通知电子邮件

从数据流开始

威胁建模从绘制数据流开始。我的数据流很短:

  1. 访问者在浏览器中填写表单。
  2. 浏览器将 JSON 发送到 POST /api/contact,一个运行在 Cloudflare Workers 上的小型 Hono 应用程序。
  3. Worker 验证输入,检查滥用,并将消息存储在 D1(SQLite)数据库中。
  4. Worker 向我发送一封通知电子邮件,访客的地址作为 Reply-To。

列表中的每个箭头都是一个信任边界:浏览器到 API,API 到数据库,API 到电子邮件。每个边界都是一个不受信任的输入进入我关心的东西的地方。对于每个边界,我问了 STRIDE 模型背后的问题:有人可能 伪造 东西,篡改 数据,否认 他们所做的事情,泄露 信息,拒绝服务,或 升级 权限?

对于联系表单,三个威胁浮现到顶部:滥用(垃圾邮件和洪水攻击),注入(到数据库和电子邮件中),和 隐私(我现在持有其他人的姓名、电子邮件和文字)。

第一层:假设浏览器在撒谎

第一条规则是浏览器中的表单是一个建议,而不是一个控制。任何人都可以跳过它并使用 curl 发送请求。因此,每个检查都在服务器上再次发生:

  • 正文必须是有效的 JSON,否则请求将被拒绝,返回 400。
  • name 必须存在,且最多 100 个字符。
  • email 必须匹配一个基本模式,且最多 200 个字符。
  • intent 必须是固定列表中的一个(project,design,teaching,speaking,other)。这是一个 允许列表:我不尝试猜测什么是坏输入,我只接受什么是好输入。
  • message 必须在 10 到 5,000 个字符之间。

长度限制不仅仅是整洁。它们为一个请求可以消耗的存储和电子邮件数量设置了一个上限,这在我们讨论洪水攻击时很重要。

相同的想法也适用于CORS。API仅向允许列表(我的域名及其预览部署)上的来源返回CORS头部。但是我不认为CORS是一个安全控制。它阻止其他网站使用访客的浏览器调用我的API;它对直接与API交谈的脚本无效。了解哪个控制保护哪个攻击者是工作的一半。

第2层:使垃圾邮件变得昂贵

垃圾邮件是任何联系表单最可能的攻击,因此它有三个独立的层。其中任何一个都不足以单独使用;它们共同提高了成本。

诱饵陷阱。 表单有一个隐藏的company字段,人们从不看到也从不填写。简单的机器人填写每个字段。如果company有值,API返回{ ok: true }并悄悄地丢弃消息。返回成功是故意的:错误会教导机器人作者他们被抓住了。

速率限制。 每个 IP 地址每小时有五次提交,使用 Cloudflare KV 计数,有效期为一小时。超过此限制后,API 返回 429 Too Many Requests。计数是在 之前 机器人检查,因此始终失败的脚本仍然会消耗自己的配额。

挑战。 表单使用Cloudflare Turnstile,服务器在存储任何内容之前使用Cloudflare验证令牌。密钥存储在Worker密钥中,从不存储在存储库或页面中。

这是小规模的深度防御:诱饵陷阱可以免费捕获懒惰的机器人,Turnstile可以捕获其余的大部分,速率限制可以限制任何通过两个限制的损害。

第3层:注入,在两个人们容易忘记的地方

大多数开发人员都知道SQL注入。向D1插入使用参数化查询:

await c.env.DB.prepare('INSERT INTO messages (name, email, intent, body) VALUES (?, ?, ?, ?)')
  .bind(name, email, intent, message)
  .run();

访客的文本作为数据传递,永远不会粘贴到SQL字符串中,因此包含'); DROP TABLE messages; --的消息存储为确切的消息。

第二个注入点不太出名:电子邮件头部。通知电子邮件是手动构建的MIME消息,访客的名称和电子邮件最终出现在Subject和Reply-To头部中。电子邮件头部由行断字符隔开。如果访客的“名称”包含行断字符后跟Bcc: [email protected],粗心的实现会添加新头部并将我的服务器变成垃圾邮件中继。因此,每个值在传递到头部之前都会经过一个函数,该函数用空格替换行断字符,并且非ASCII名称(如Kɔfi或李明)会被正确编码,而不是以原始形式传递。

电子邮件正文是纯文本,而不是HTML,因此,当我阅读时,充满<script>标签的消息只是文本。

第4层:那些做了很多事情的枯燥头部

网站本身是通过严格的安全头部提供的:仅允许来自我自己的域名和我使用的两个Cloudflare服务的脚本的内容安全策略,frame-ancestors 'none'和X-Frame-Options: DENY防止点击劫持,HSTS使浏览器始终使用HTTPS,以及Permissions-Policy关闭摄像头、麦克风和位置。所有这些都不是令人兴奋的,这就是重点。良好的安全性主要是持续做出枯燥的决定。

哪里仍然存在弱点

找不到弱点的威胁模型不是威胁模型。以下是我所知道的:

  • 基于IP的速率限制会惩罚共享网络。 许多移动网络将大量用户放在少量共享IP地址(运营商级NAT)后面。在这样的网络中,一个滥用用户可能会耗尽无辜用户的每小时限制,而这些用户恰好共享同一个地址。对于个人网站来说,这种折衷是可以接受的,但对于为非洲移动用户构建的服务来说,这是一个真正的公平问题,我希望更多地思考这个问题。
  • 速率限制是近似的。 KV最终是一致的,读取然后写入计数器不是原子的,所以快速的并行请求可以稍微超过限制。这对于抑制垃圾邮件是可以的;但是,对于限制登录尝试(我会使用强一致性存储)来说,这是不行的。
  • 'unsafe-inline'仍然在脚本策略中。 静态网站使用一些内联脚本,因此CSP允许它们。这削弱了CSP对跨站脚本攻击的保护。正确的解决方法是为每个内联脚本使用哈希或nonce。
  • 消息将被无限期保留,以明文形式存储。 目前没有保留策略。在Ghana的2012年数据保护法(第843号法案)和其他地方的良好做法下,我只应保留个人数据,直到我需要它。计划任务可以在固定时间后删除消息,这是下一步的明显解决方案。

我已经对这四个问题进行了排名,并写下了如何解决每个问题的计划,详见后续文章。

我从中得到了什么

两件事让我印象深刻。

首先,安全始于“谁在这个端点的另一侧?”这个问题,而不是工具列表。Turnstile、KV和参数化查询之所以有用,是因为威胁模型指出了它们的位置。

其次,小而不起眼的功能是风险隐藏的地方。 没有人会对联系表单进行渗透测试。正是那些看起来太小而不重要的系统,例如学校的成绩门户、教堂的捐赠页面、一个小型非政府组织的注册表单,才会以最少的保护保存真实的人们的数据。这些是我最关心的系统,我希望我的研究能够使它们更安全。

如果你发现我错过了什么,我真的很想知道:联系我。是的,通过表格。

Festus Boamah

一起合作

一起打造有意义的东西。

有产品要开发、品牌要塑造,或课程要开设?很期待听到你的想法。