永远不要相信用户输入
注册表单大概是 Web 开发里最普通不过的东西了。几个文本框、一个提交按钮、成功时弹出一条提示。
但它同时也是应用的大门。用户输入的每一个用户名、邮箱地址、评论和搜索关键词,都是你的应用没有写过、来自你无法控制的机器的数据。
其中大部分都是无害的。但只要有一个访客好奇地想试试,输入一些你没料到的东西,问题就来了。
把用户输入当成安全数据来处理,是 Web 开发中代价最高的一个假设。
本节要攻击的表单
贯穿全文的示例是一个带前端和后端的注册表单,而且它是被故意写得很糟糕的。
| 部分 | 作用 |
|---|---|
front-end/index.html | 表单的标记结构:输入框、提交按钮,以及成功和错误信息的容器。 |
front-end/app.ts | 读取输入内容,向后端发起 POST 请求,并展示结果。 |
front-end/types.ts | 与后端的类型保持一致,让客户端知道响应数据的结构。 |
back-end/server.ts | 启动 Express,解析 JSON,挂载路由,监听 3000 端口。 |
back-end/routes/auth.ts | /api/register-vulnerable 接口。它完全跳过了校验,直接把原始输入原样返回。 |
back-end/state/mockDb.ts | 数据库的替代品,让注册人数可以变化,而不需要真正的数据库。 |
这个接口的名字本身就是一个警示标签。它的任何部分都不应该出现在生产环境里。它存在的目的,就是为了让接下来几章的攻击有地方可落地。
去掉多余部分后,这个处理函数其实就是这样:
router.post('/api/register-vulnerable', (req, res) => {
const { name, email, password } = req.body
mockDb.users.push({ name, email, password })
res.json({ success: true, user: { name, email } })
})三个字段进来,三个字段被存起来,两个字段被送回去。中间没有任何一步去问一句:name 是不是真的像个名字,email 里有没有 @,或者这些字段到底有多长。
一开始接触一个被故意弄坏的应用,感觉会有点怪。它是故意写错的,所以当你盯着它看的时候,失败的过程是清清楚楚摆在眼前的。
浏览器无法强制执行任何规则
HTML 自带免费的校验功能。给输入框加上 required,表单就不会在为空的情况下提交。设置 type="email",浏览器就会检查这个值看起来像不像一个邮箱地址。
这确实有用,但它保护不了任何人。
一个想绕过它的人,可以关掉 JavaScript,在开发者工具里直接改属性,或者干脆跳过页面,用 curl 直接把请求发给接口。你的表单只是一个客户端。而接口能接受来自任何地方的请求。
浏览器校验只是对老实用户的一种照顾
它能捕捉笔误、省去一次往返请求。但它不是安全控制手段,因为要不要执行这些检查,决定权在攻击者手里。
所以这个表单一开始就关掉了浏览器校验:
<form novalidate>从零开始,能让这一点更容易记住。每一项真正重要的检查,都要在服务器端有意识地加上——因为那是请求在传输途中无法被篡改的地方。
服务器运行在你自己的机器上。这才是唯一一个不会被你正在检查的对象关掉检查的地方。
输入变成 bug 的三条路径
接下来三章会追踪同一个值走向三个不同的目的地,而目的地决定了会出什么问题。
| 输入最终去哪 | 会出什么问题 | 对应章节 |
|---|---|---|
| 被渲染进页面 | 一个访客注入的脚本,在另一个访客的浏览器里运行 | 跨站脚本攻击 |
| 消耗服务器资源 | 一个超大或精心构造的请求耗尽内存、存储或连接数 | 拒绝服务攻击 |
| 被拼接进查询语句 | 输入变成 SQL 语句,读取、篡改或删除数据 | SQL 注入 |
同一个字段可以同时喂给这三种情况。一个名字既会进入页面,也会进入存储,还会进入查询语句,所以一个未经检查的值,就有三种方式能伤到你。
进入页面的 HTML?进入数据库?进入查询语句?进入一封邮件?每个目的地,都有自己一套把文本误读成指令的方式。
没有一劳永逸的方案,只有层层防护
每一种漏洞都有对应的防御手段,但没有哪一种能单独解决全部问题。把这些防护手段叠加起来使用,专业说法叫纵深防御:假设任何一层都可能失效,确保它背后还有别的防线撑着。
对这个表单来说,各层防护分别是:
- 浏览器校验能捕捉笔误、省去一次往返请求。但对存心要绕过的人,它拦不住。
- 服务器端的模式校验在处理函数接触请求之前,就判断这个请求的结构对不对。本节后面会用 Zod 来做这件事,这是一个用来描述你期望的数据结构、并据此校验数据的库。
- 安全输出在渲染数值时对其进行转义,让存储的文本始终只是文本。
- 参数化查询让查询语句的结构和填入其中的数据保持分离。
一次攻击要想得逞,必须闯过全部四层防线。一层出错,只是个 bug;一层出错而其他层都不存在,那就是一次安全事故。
你总会有某一层做错的时候。而正是有了四层防护,才让"错一次"这件事变得可以承受。
动手试试
再来看一遍这个注册处理函数:
router.post('/api/register-vulnerable', (req, res) => {
const { name, email, password } = req.body
mockDb.users.push({ name, email, password })
res.json({ success: true, user: { name, email } })
})回答关于它的三个问题:
- 它对
req.body里的值做了哪些假设? - 如果请求是用
curl发的,而不是从表单来的,哪些假设还能成立? - 处理函数拿到每个字段之后,它们各自会流向哪里?
对照一下你的答案
1. 它做了哪些假设。 一共五点,没有一个是经过检查的:
- 三个字段都传过来了。
- 每一个都是字符串。
- 没有哪个长得离谱。
email真的是个邮箱地址。- 不管是谁接下来处理这些值,都不会把它们当成指令来解释。
每一条,都只是一个美好的期望。
2. 用 curl 发请求,哪些假设还能成立。 一条都不行。唯一在强制执行这些规则的,只有表单自己的标记结构,而直接发给接口的请求根本不会经过表单。不管请求是怎么来的,处理函数的行为完全一样,因为它根本分辨不出区别。
3. 每个字段流向哪里。 name 和 email 会存进去,然后在响应里被送回来,前端会把它们渲染进成功提示信息里。仅仅一次提交,每个值就已经同时到达了页面和数据存储这两个地方。
password 会原封不动、以明文形式存起来,这本身就是一个 bug:密码应该经过哈希处理,绝不应该以可读的形式保存。如果把模拟存储换成真正的数据库,这三个字段还会一并进入查询语句。
这些假设,正是接下来三章要一一打破的对象。
接下来会发生什么
表单已经搭好了,浏览器也不再假装能保护它了,而它当前接受的每一个值,都还处在被信任的状态。
跨站脚本攻击 将拉开破坏的序幕,用一个能在别人浏览器里执行代码的姓名字段作为突破口。

