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,
  },
})

passwordagebio 都经过了校验,但没有被返回。之所以能一直保持这样,是因为响应体是逐个字段搭建出来的,新加进 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 },
  })
})

接下来往哪走

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

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

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