Skip to content
This page has been auto-translated and may contain errors.View in English

如何思考安全问题

一个功能可以运行得完美无缺,却依然不安全。想象一个很小的订单接口:

js
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id)
  res.json(order)
})

这个接口读取一个 id,找到对应订单并返回。如果你用自己的订单去测试,看起来一切正常。现在把 URL 里的 id 改一改。如果服务器从不检查这个订单归谁所有,一个已登录的用户就能读到另一个用户的账单。

这就是安全思维:不能只问代码能不能用,还要问别人能拿它做出什么事来。

Juno如何思考安全问题 能跑通的代码回答的是一个问题:这个功能是不是做到了我们想要的效果?

安全考虑的是第二个问题:如果有人用一种我们没预料到的方式来用它,会发生什么?

Juno如何思考安全问题 上面那个订单接口里的问题,并不是 JavaScript 层面的 bug。处理函数照常运行,查询正常返回,响应也是合法的 JSON。

审查时该做的,是把那个隐藏的判断明确说出来:这段代码检查了订单是否存在,但没检查这个用户是否有权读取它。一旦你能把缺失的判断说清楚,你就能写出缺失的测试。

Juno如何思考安全问题 大多数应用安全漏洞,其实就是普通的产品逻辑路径,只不过屋子里多了一个心怀恶意的调用者。产品逻辑本身照样能跑通,这正是为什么“happy path”测试对你笑脸相迎,而事故报告却在暗中磨牙。

把这个功能读成一组承受压力的"声明":URL 声明了一个 id,会话声明了一个用户,请求体声明了意图。服务器要做的,是决定哪些声明能变成事实。

留意边界

信任边界 指的是你所掌控的代码,和某个可以向你传入你没有选择过的值的地方之间的分界线。

浏览器就站在这条线的另一侧。用户可以修改 URL、编辑隐藏的表单字段、重复提交某个查询参数、删掉一个 cookie,甚至绕过你的前端直接手写请求发送过去。

这就决定了:浏览器不是做重要判断的地方。

浏览器端的检查有用,但它不是真正的把关点

客户端校验能帮用户更顺利地填表单,及早发现拼写错误,带来更好的体验。

但服务器仍然必须再检查一遍这个值,因为真正保护数据的是服务器。

下面是同一个订单接口,把缺失的判断明确加了回来:

js
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id)

  if (!order || order.userId !== req.user.id) {
    return res.status(404).json({ error: 'Order not found' })
  }

  res.json(order)
})

关键的一行是 order.userIdreq.user.id 的比较。服务器在表达这样一个意思:这个订单也许存在,但它还必须属于发起请求的这个人。

只有当服务器能自行证明这个判断成立,请求才应该被放行。

Juno留意边界 把浏览器想象成前台的访客。它可以提出请求,但没有权力决定自己能看到什么。

把重要的检查放在服务器上。那里才是你的应用能够拿请求和它已经信任的数据做对比的地方。

Juno留意边界 信任边界,就是一个值从你不掌控的地方,跨入将要基于它做判断的代码时所经过的那道线。

一个好用的审查技巧:列出处理函数读取的每一个值,然后标出哪些来自调用方。任何调用方选择的值,只要会影响数据、身份、权限或负载,就都需要一条服务端规则来把关。

Juno留意边界 边界并不止于网络接口。后台任务、管理工具、webhook、环境配置和内部服务,都可能携带着来自你当前代码没有选择过的地方的值。

这条线该画在哪里,取决于你愿意在多大程度上为另一个系统的失败兜底。一旦你画出这条线,就欠下了一份责任:在跨越处加一个检查、一个测试、一条错误处理路径,再给未来某个倒霉的工程师留一点线索,说明它为什么存在。

给可能出的问题起个名字

一旦你发现了一个有风险的判断,就给可能出现的失败起个名字:

问题安全术语例子
谁能读到这个?机密性(Confidentiality)一个用户读到了另一个用户的订单。
谁能改动这个?完整性(Integrity)一个用户在结账前改动了价格。
大家还能正常使用吗?可用性(Availability)一个请求就让应用对所有人都变卡。

这三者合在一起,就是所谓的 CIA 三要素:机密性完整性可用性

泄露的账单是机密性问题。可被篡改的隐藏价格字段是完整性问题。一个允许单个请求查询上百万条记录的搜索接口,是可用性问题。

有些漏洞不止影响一个方面。如果一个用户能编辑另一个用户的收货地址,那他既能看到它,也能改动它。

Juno给可能出的问题起个名字 先问这三个问题,再去套那三个术语。谁能读到它?谁能改动它?大家还能正常用吗?

这几个问题能把一种模糊的担忧,变成一件你能说清楚的事情。当你能给伤害起个名字,一份 bug 报告就会清晰得多。

Juno给可能出的问题起个名字 机密性、完整性和可用性能帮你选对修复方式。

读取泄露需要加访问检查。错误用户的改动需要服务端的权限判断。开销大的接口需要限制大小、频率或时长。同一个接口,问题的性质不同,修法也不同。

Juno给可能出的问题起个名字 这三个属性会把大家通常不愿明说的取舍暴露出来。账户锁定机制能防止别人靠猜密码来突破机密性,但也给了攻击者一种拦住真实用户的手段。

可用性是团队最常主动拿去做交易的属性。要么在设计阶段就把这种取舍讲清楚,要么就等着某天故障发生时,有人喝着更凉的咖啡、带着更少的选项重新发现它。

换位思考

"像攻击者一样思考"这句话听起来有点戏剧化。放到日常开发里,其实要平淡得多。挑一个输入,问自己:

  • 如果这个值缺失了会怎样?
  • 如果它比预期长得多会怎样?
  • 如果它的类型不对会怎样?
  • 如果它属于另一个用户会怎样?
  • 如果同一个请求被发送了成千上万次会怎样?

拿一个个人资料表单来说。页面上可能有一个 displayName 的文本输入框、一个隐藏的 userId,还有一个提交按钮。原本设想的路径很简单:已登录用户更新自己的名字。

但安全的思路会问一些不同的问题。userId 为什么会出现在表单里?如果它指向别人会怎样?服务器用的是已登录的会话信息,还是直接相信了提交上来的这个字段?

隐藏不等于可信

一个隐藏的表单字段,只是对页面隐藏,并不是对发送请求的人隐藏。

如果这个值很重要,就应该在服务器端从会话或数据库里重新推导出来。不要指望浏览器把一个服务器早就知道的判断结果原样送回来。

同样的习惯在表单之外也适用。文件上传会声明一个文件名和内容类型。令牌会声明一个身份。webhook 会声明某个事件发生了。在你的代码即将依赖某个声明之前,先检查这个声明本身。

Juno换位思考 从一个字段开始。问问如果它缺失、过长、类型不对、属于别人,或者被反复发送,会发生什么。

这就是安全思维的一个简单、可上手的版本。

Juno换位思考 "攻击者思维"真正有用的版本,其实是"输入思维"。读处理函数的代码,标出调用方做出的每一个声明:身份、id、角色、价格、文件名、返回地址、内容类型。

然后追问每个声明是在哪里被检查的。如果某个声明在检查发生之前就已经影响了判断,那就说明这里有一个值得测试的漏洞形态。

Juno换位思考 按可达性给这些问题排优先级。一个普通已登录用户只需一次请求就能触发的漏洞,应该比那种需要精确的时机、内部人员权限,或者 2019 年遗留下来的旧部署开关才能触发的漏洞更优先关注。

这不代表边缘情况就毫无危害。它只是让审查聚焦在真正的攻击者、无聊闲逛的用户和自动化扫描工具午饭前就会去尝试的那些路径上。

接下来往哪走

这种思维方式,正是为课程接下来的内容做铺垫。

威胁建模要在功能上线之前就问清楚可能出什么问题。OWASP 为线上应用中的各类漏洞提供了一套通用的命名方式。bug 分级则能帮你决定先修哪些问题。

把这个循环保持得小而清晰:

  1. 找到边界。
  2. 给可能出的问题起个名字。
  3. 在服务器端检查这个声明。