构建一个经过校验的端点
中间件已经写好了,schema 语言也已经熟悉了。剩下的就是让它们碰头的那个路由。
从这一节一开始,注册表单就一直提交到 /api/register-vulnerable,这是一个跳过所有检查的端点。这一章要构建取代它的那个端点。
把中间件接到路由上
Express 允许在路径和处理函数之间插入中间件:
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 } })
},
)三个位置:路径、检查、处理函数。处理函数只有在它前面的所有中间件都成功执行完之后才会运行,所以校验失败的请求永远到不了它那里。
把表单指向新路径,旧的端点就不再起作用了:
// front-end/app.ts
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})这是个小事,但积少成多。规则摆在你能看到的地方,处理函数只专心做真正该做的事。
第一版 schema
先从两个字段开始,足够证明这条链路是通的:
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 填充完整。
先让一个字段被拒绝、一个字段通过,再逐步加上其余的。等一个九个字段的 schema 从一开始就没跑通过,再去调试就是苦不堪言的下午了。
把 schema 挪出去
内联 schema 对两个字段来说没问题,等到有九个字段时就不合适了。给它一个单独的文件:
// 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>路由把两者都导入:
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'
const userData: RegisterInput = (req as any).validatedData现在这个 schema 可以复用了,类型也自动从它推导出来,处理函数拿到的数据也是有类型的。给 schema 加一个字段,RegisterInput 就自动跟着多出这个字段,不用再改第二遍。
现在一个定义要干两份活:程序运行时它负责校验数据,你写代码时它负责告诉 TypeScript 数据的形状。两者不可能出现不一致,因为本来就只有这一份定义。
扩充 schema
现在换成真正要用的字段,每一个都说明自己接受什么、又对值做了什么处理:
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() 的用途。
这就是用这种方式描述数据的好处。哪怕规则有九个字段那么多,你还是能靠读一句话就检查清楚其中任何一条。
返回什么
处理函数只返回客户端应该看到的字段:
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 的字段不会不小心泄露到响应里。
密码被仔细检查,却从不会被送回去。之所以随着 schema 不断扩充这条规则依然成立,是因为响应体里每个字段都是显式点名的。
练习一下
下面这个 schema 有三个问题:一个会拒绝合法输入,一个会接受本不该通过的输入,还有一个会泄露信息。
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 校验过的每个字段都会回传给客户端,包括以后新加的字段。应该显式点名你打算返回的字段。
修正后的版本:
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 },
})
})接下来往哪走
这一节开头是一个什么都相信的表单,结尾是一个只相信自己检查过的东西的表单。九个字段说明了服务端接受什么,中间件在任何处理函数运行之前就把这些规则强制执行了,响应体也只说了它打算说的那部分。
这一节开篇提到的三种攻击,靠的都是没人检查过的输入。现在这一切都在边界处被统一处理了,而且是在一个你随时能读懂的文件里。
身份认证与授权 会展开下一个问题。注册端点创建了一个账号,但在那之后,应用对请求背后到底是谁完全没有概念。

