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

构建一个经过校验的端点 ​

中间件已经写好了,schema 语言也已经熟悉了。剩下的就是让它们碰头的那个路由。

从这一节一开始,注册表单就一直提交到 /api/register-vulnerable,这是一个跳过所有检查的端点。这一章要构建取代它的那个端点。

把中间件接到路由上 ​

Express 允许在路径和处理函数之间插入中间件:

ts
import { validate } from '../middleware/validate.js'
import { registerSchema } from '../schemas/userSchema.js'

router.post(
  '/api/register',
  validate(registerSchema),
  (req, res) => {
    const userData = (req as any).validatedData

    res.status(201).json({ success: true, user: { email: userData.email } })
  },
)

三个位置:路径、检查、处理函数。处理函数只有在它前面的所有中间件都成功执行完之后才会运行,所以校验失败的请求永远到不了它那里。

把表单指向新路径,旧的端点就不再起作用了:

ts
// front-end/app.ts
const response = await fetch('/api/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(formData),
})
Juno把中间件接到路由上 只要读一读路由定义,就能知道它接受什么样的数据,根本不用打开处理函数看代码。

这是个小事,但积少成多。规则摆在你能看到的地方,处理函数只专心做真正该做的事。

Juno把中间件接到路由上 在有漏洞的旧路由旁边搭建安全的新路由,再把表单切换过去,这个习惯值得照搬。旧路径在新路径被验证通过之前照常工作,所以改动过程中不会出现中断。

大家最容易忘的是最后一步:表单迁移完之后,要把旧端点删掉。一个没人用的、有漏洞的路由依然是活着的路由,它不会因为前端不再调用它就停止响应。

Juno把中间件接到路由上 链条中的顺序本身就是一种行为,而不只是风格问题。中间件是从上到下依次执行的,所以放在身份认证检查之后的校验,只会看到已经证明了自己身份的请求。

限流通常应该放在最前面,因为解析请求体的开销比单纯计数一次请求要大得多,否则攻击者发送大量畸形负载,等于在用很低的成本消耗你的 CPU。

201 这个状态码也值得较真:它表示“已创建”,通常还会配一个指向新资源的 Location 响应头。

第一版 schema ​

先从两个字段开始,足够证明这条链路是通的:

ts
import * as z from 'zod'

const registerSchema = z.object({
  email: z.email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

把表单空着提交,两条错误信息都会一起出现在 400 响应里,页面可以直接把它们放到对应字段旁边。填一个真实的地址和一个足够长的密码,处理函数就会执行。

这样一整套流程就跑通了。接下来要做的都是把 schema 填充完整。

Juno第一版 schema 只用两个字段是刻意的。这样小的规模,能让你在出错的地方还不多时,就先看到整条链路是通的。

先让一个字段被拒绝、一个字段通过,再逐步加上其余的。等一个九个字段的 schema 从一开始就没跑通过,再去调试就是苦不堪言的下午了。

Juno第一版 schema 先测试失败的路径,再测试成功的路径。人总是忍不住先填有效输入看看是不是返回 201,但那只能证明处理函数确实在运行。

什么都不填提交,应该得到一个 400,每个字段都带着对应的错误信息。如果它给你返回了 201,说明中间件根本没接上,而一次合法的提交本身也说明不了任何问题。

Juno第一版 schema 最小长度是大多数服务止步于此的控制手段,而 8 是一个很低的门槛。长度是单个因素里最有效的一个,所以现在的建议是 12 位以上。

更有用的做法是去比对已知泄露密码库,因为“Password123!”能满足几乎所有写得出来的复杂度规则,而组合规则反而会把人逼向可预测的替换写法,再加上一张贴在显示器上的便签纸。

存储是另外一半:用 bcrypt 或 argon2 做哈希,绝不保留原始密码。

把 schema 挪出去 ​

内联 schema 对两个字段来说没问题,等到有九个字段时就不合适了。给它一个单独的文件:

ts
// back-end/schemas/userSchema.ts
import * as z from 'zod'

export const registerSchema = z.object({
  email: z.email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export type RegisterInput = z.infer<typeof registerSchema>

路由把两者都导入:

ts
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'

const userData: RegisterInput = (req as any).validatedData

现在这个 schema 可以复用了,类型也自动从它推导出来,处理函数拿到的数据也是有类型的。给 schema 加一个字段,RegisterInput 就自动跟着多出这个字段,不用再改第二遍。

Juno把 schema 挪出去 把文件挪出来只是普通的整理工作。真正值得注意的是导出的类型那一行。

现在一个定义要干两份活:程序运行时它负责校验数据,你写代码时它负责告诉 TypeScript 数据的形状。两者不可能出现不一致,因为本来就只有这一份定义。

Juno把 schema 挪出去 一个 schemas 文件夹会变成大家查阅“这个 API 接受什么参数”的地方,这个用途远不止于校验。它是“这个端点接收什么”这个问题最诚实的答案,之所以诚实,是因为真正做检查的就是这段代码本身。

把相关的 schema 放在一起,让它们互相组合。loginSchema 完全可以从用户 schema 里挑出需要的字段,而不用重新写一遍,规则就始终只有一处。

Juno把 schema 挪出去 因为 schema 只是一个普通模块,前端也可以导入同一个文件,在提交之前先检查一遍表单。同一份定义,两处使用,不会出现不一致,而服务端的检查依然是真正说了算的那一道。

不妨把这个类型推得更远一些,别只用在处理函数里。让服务层函数把 RegisterInput 作为参数类型,编译器就会阻止任何人拿一个未经校验的对象去调用它们,把“务必先校验”变成构建阶段就强制执行的规则。

有一点要留意:z.infer 得到的是输出类型,所以如果 schema 里有类型强制转换,它描述的其实是解析之后的形状。这正是你想要的那个类型,也是处理函数不该直接用 req.body 的另一个理由。

扩充 schema ​

现在换成真正要用的字段,每一个都说明自己接受什么、又对值做了什么处理:

ts
export const registerSchema = z.object({
  name: z
    .string('Name is required')
    .trim()
    .min(2, 'Name must be at least 2 characters')
    .max(50, 'Name must be 50 characters or fewer')
    .regex(/^[a-zA-Z\s\-'.]+$/, 'Letters, spaces, hyphens, apostrophes and periods only'),

  username: z
    .string('Username is required')
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be 20 characters or fewer'),

  email: z
    .string('Email is required')
    .trim()
    .toLowerCase()
    .pipe(z.email('Enter a valid email address')),

  age: z.coerce
    .number('Age is required')
    .int('Age must be a whole number')
    .min(13, 'You must be at least 13')
    .max(120, 'Enter a real age'),

  password: z
    .string('Password is required')
    .min(8, 'Password must be at least 8 characters')
    .regex(/^[A-Za-z0-9_]+$/, 'Letters, numbers and underscores only'),

  bio: z.string().max(500, 'Bio must be 500 characters or fewer').optional(),
})

这几个字段里发生了四件事。

每个字符串字段都有最大长度限制。 这一行就解决了 拒绝服务 那一节提到的超大输入问题。

age 会被强制转换。 HTML 表单发送的是字符串,所以 z.coerce.number() 会先转换再检查,而 .int() 会拒绝像 21.5 这样的值。

email 会先规范化再校验。 .trim() 和 .toLowerCase() 先执行,然后 .pipe() 把清理过的值交给邮箱格式检查。

bio 是可选的。 不填没关系,但填 600 个字符就不行。

链式调用中顺序很重要

z.email().trim() 是先校验后 trim,所以一个前面多了个空格的地址,会在 trim 起作用之前就被判定为无效。已在 Zod 4.5.4 上验证:' [email protected] ' 会被拒绝。应该先清理值,再校验结果,这正是 .pipe() 的用途。

Juno扩充 schema 一次读一个字段,每一行都是一条简单明了的规则。姓名是文本,去掉多余空格,长度在 2 到 50 个字符之间,只能用姓名里会出现的字符。

这就是用这种方式描述数据的好处。哪怕规则有九个字段那么多,你还是能靠读一句话就检查清楚其中任何一条。

Juno扩充 schema 在 schema 里做规范化,是保持存储一致性的关键。没有 toLowerCase 的话,[email protected] 和 [email protected] 就是两个账号,等到有人登录不上你才会发现问题。

对姓名用正则要小心。这里的模式会拒绝所有用拉丁字母以外文字写的姓名,也会拒绝不少用拉丁字母写的姓名。通常长度限制才是合适的控制手段,如果确实需要字符检查,也要有意识地决定它排除掉哪些文字体系。

Juno扩充 schema 密码这条正则最值得商榷。限制只能用字母、数字和下划线,会挡住空格和符号,也就排除了长密码短语和密码管理器生成的密码,而这对攻击者根本没有任何影响:密码值只会被哈希,从不会被解释执行。

对密码设字符白名单,通常是不安全查询构建那个时代留下的老习惯,那个问题真正的解法是参数化查询。给密码设一个宽松的最大长度,其他一律放行就好。

这个最大长度看似不起眼,其实很重要。bcrypt 在 72 字节处会截断,没有上限的话,一个很长的密码短语后面的部分会被悄悄忽略;而哈希本身又是刻意做得很慢的,所以一个没有长度限制的字段,等于把拒绝服务攻击对准了你自己的 CPU。

返回什么 ​

处理函数只返回客户端应该看到的字段:

ts
const userData: RegisterInput = (req as any).validatedData

// 真正的业务逻辑放在这里:用 bcrypt 或 argon2 对密码做哈希,
// 用参数化查询存储用户,发送验证邮件。

res.status(201).json({
  success: true,
  user: {
    id: Date.now(),
    name: userData.name,
    username: userData.username,
    email: userData.email,
  },
})

password、age 和 bio 都经过了校验,但没有被返回。之所以能一直保持这样,是因为响应体是逐个字段搭建出来的,新加进 schema 的字段不会不小心泄露到响应里。

Juno返回什么 校验一个字段,和返回一个字段,是两个独立的决定。

密码被仔细检查,却从不会被送回去。之所以随着 schema 不断扩充这条规则依然成立,是因为响应体里每个字段都是显式点名的。

Juno返回什么 要避免的写法是 res.json({ user: userData })。它今天能正常工作,但会把以后 schema 里加的每一个字段都变成响应里的字段,等哪天有人加了一个供内部使用的字段,它就会原样发给所有客户端。

显式点名每个字段无非多写几行代码,却少留一个泄露信息的口子。

Juno返回什么 更持久的做法是搞一个输出 schema:用第二个 Zod schema 描述响应结构,在数据出去之前解析一遍。这样一来,你的 API 到底返回什么就只有一处描述,会被检查,也不可能被不小心扩大范围。这是一份不会和代码脱节的契约。

用 Date.now() 当 id,拿来做演示没问题,放到生产环境就不对了:并发情况下会撞车,还会泄露创建时间。真正该用的是 UUID(128 位随机标识符格式),或者数据库自增序列。

练习一下 ​

下面这个 schema 有三个问题:一个会拒绝合法输入,一个会接受本不该通过的输入,还有一个会泄露信息。

ts
export const profileSchema = z.object({
  email: z.email().trim(),
  displayName: z.string().min(2),
  age: z.number().min(13),
})

router.post('/api/profile', validate(profileSchema), (req, res) => {
  const data = (req as any).validatedData
  res.status(201).json({ success: true, user: data })
})
对照一下你的答案

1. z.email().trim() 会拒绝合法输入。 trim 是在邮箱检查之后才执行的,所以一个末尾多粘了个空格的地址,会在还没来得及被清理之前就校验失败。已在 Zod 4.5.4 上验证。应该用 z.string().trim().pipe(z.email())。

2. displayName 没有设最大长度,age 也没有做强制转换。 一行一个问题。没有 .max() 意味着这个字段可以接受任意长度的值,这正是前面那一节讲过的拒绝服务问题,从表单又钻了进来。而 z.number() 会拒绝 HTML 表单实际发送的字符串,所以 age 需要用 z.coerce.number(),顺便再加上 .int() 和 .max()。

3. user: data 把所有内容都返回了。 schema 校验过的每个字段都会回传给客户端,包括以后新加的字段。应该显式点名你打算返回的字段。

修正后的版本:

ts
export const profileSchema = z.object({
  email: z.string().trim().toLowerCase().pipe(z.email('Enter a valid email address')),
  displayName: z.string().trim().min(2).max(50),
  age: z.coerce.number().int().min(13).max(120),
})

router.post('/api/profile', validate(profileSchema), (req, res) => {
  const data = (req as any).validatedData
  res.status(201).json({
    success: true,
    user: { displayName: data.displayName, email: data.email },
  })
})

接下来往哪走 ​

这一节开头是一个什么都相信的表单,结尾是一个只相信自己检查过的东西的表单。九个字段说明了服务端接受什么,中间件在任何处理函数运行之前就把这些规则强制执行了,响应体也只说了它打算说的那部分。

这一节开篇提到的三种攻击,靠的都是没人检查过的输入。现在这一切都在边界处被统一处理了,而且是在一个你随时能读懂的文件里。

身份认证与授权 会展开下一个问题。注册端点创建了一个账号,但在那之后,应用对请求背后到底是谁完全没有概念。