接口类型断言挡不住恶意请求
文章用 TypeScript 接口示例说明,类型断言不会校验请求体,错误字段类型和额外字段仍可能进入后续处理。请求、Webhook 等外部数据应视为 unknown,并在运行时验证。
AI 深度解读
给请求体写上 TypeScript 类型,并不会让 API 自动拒绝错误数据。原文指出,as CreateUser 只是让编译器相信开发者,客户端传来的字符串、额外字段,仍可能一路进入业务代码和数据库。
作者给出的办法是:在数据进入程序时做运行时校验,再从校验规则推导类型。这对写 Next.js、Node 接口的人很实用,但要分清三件事:数据格式是否合法、哪些字段允许写入、当前用户有没有操作权限。
为什么类型写对了,接口仍然可能出错
原文的接口先执行 (await req.json()) as CreateUser,然后把 body 直接交给 User.create(body)。声明中的 name、email 是字符串,age 是数字,看起来没有问题。
但客户端可以发送 age: "banana",再附带一个 role: "admin"。类型断言不会检查对象,也不会删除字段,更不会把字符串转换成数字。
TypeScript 类型在运行前就被擦除了,运行时留下的只是客户端构造的 JavaScript 对象。
后果取决于后续代码。缺失的 email 被调用 .toLowerCase(),可能抛出异常;错误的 age 可能被数据库拒绝、转换或保存;如果直接写入的对象包含敏感字段,还可能出现批量赋值漏洞(mass assignment):客户端替服务端决定了原本不该开放的字段。
原文用 Mongoose 提醒了一个容易误判的边界:丢弃数据库 schema 之外的字段,只能拦住数据库不认识的字段。如果 role、isAdmin、plan、price 本来就在 schema 里,这层机制不能判断客户端是否有权设置它们。
另外,await req.json() 自己也会因为非法 JSON 抛错。连解析异常都没有处理时,一个客户端输入错误就可能变成服务端的 500。
用 schema 建立入口规则,再让类型跟着规则走
作者推荐用 Zod 编写运行时 schema,并通过 z.infer 推导 TypeScript 类型。下面这段同时规定了字段类型和取值范围,避免维护两份各自变化的定义:
import { z } from 'zod';
const CreateUser = z.object({
name: z.string().min(1).max(80),
email: z.string().email(),
age: z.number().int().min(13).max(120),
});
type CreateUser = z.infer<typeof CreateUser>; // 类型由 schema 推导
要看的是 z.infer<typeof CreateUser>:类型来自实际执行的校验规则。名字长度为 1 到 80,年龄必须是 13 到 120 的整数,这些限制才会在运行时检查。
路由入口则分别处理 JSON 解析失败和字段校验失败,数据库只接收校验后的结果:
export async function POST(req: Request) {
let json: unknown;
try {
json = await req.json();
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parsed = CreateUser.safeParse(json);
if (!parsed.success) {
return Response.json(
{ error: 'Invalid input', issues: parsed.error.issues },
{ status: 400 }
);
}
await User.create(parsed.data); // 只写入 schema 中的字段
return Response.json({ ok: true }, { status: 201 });
}
最该盯住的是 User.create(parsed.data)。如果校验通过后仍然保存原始 json,前面建立的字段边界就被绕开了。
unknown 要求开发者先检查再使用;safeParse 返回结果供代码判断;两类输入错误都返回 400,成功创建返回 201。
原文依赖 Zod 对象 schema 默认移除未知字段的行为,因此未声明的 role 会在写库前被去掉。作者也提醒,大版本之间的 API 有差异,文中依赖的是 error.issues,但没有注明具体 Zod 版本。实际接入时,需要确认项目版本和对象 schema 配置。
自动转换最容易在哪些地方出问题
查询参数需要单独考虑转换语义。访问 /api/items?page=2,拿到的是字符串 "2",不会自动变成数字。
原文给出的规则是 z.coerce.number().int().min(1).default(1)。其中 z.coerce.number() 会调用 Number(),而 Number('') 和 Number(' ') 都得到 0。
所以,?page= 并不会自然触发默认值 1。这条规则靠 .min(1) 拒绝转换后的零;如果另一个字段允许零,空输入就可能悄悄通过。转换之前,应先决定空值代表缺失、错误,还是一个合法值。
布尔值更容易踩坑:z.coerce.boolean().parse('false') 的结果是 true,因为 JavaScript 的 Boolean() 会把非空字符串视为真。作者使用 z.enum(['true', 'false']).transform((v) => v === 'true'),明确限制输入,再做转换。
同样的入口检查还包括 URL 参数、第三方 API 响应和 webhook。webhook 验签通过,只能解决来源验证问题,payload 的结构仍然要检查。环境变量则可以在启动时集中解析 process.env,尽早暴露缺失配置。
前端表单校验当然有用,它能及时提示用户。但任何人都可以跳过页面直接调用接口,服务端必须保留自己的检查。
我的判断
我赞成作者把重点放在「外部输入先视为 unknown」和「写库只使用允许的字段」上。它们能直接约束数据流,比单纯要求团队少用 any 更具体。相比手写零散的 if 判断,从 schema 推导类型也更容易保持定义一致。
但原文没有提供事故复盘、测试结果、性能数据,也没有对比其他校验库。作者说这些检查放在他们 Next.js SaaS 项目的每个路由入口,这是实践经验,不能据此认定 Zod 是所有团队的最佳选择。
文章开头关于错误值和额外字段被保存的描述,也有前提:数据库 schema、转换规则及写入方式会影响结果。类型断言不提供运行时保护是确定的,具体会不会落库,需要看实际链路。
校验通过,只能说明请求满足输入规则,不能说明请求者有权执行操作。
比如字段完全合法,用户仍可能无权修改那条记录。字段白名单也应跟操作绑定:普通用户修改资料和管理员调整权限,允许写入的字段通常不同。schema 不能代替这层授权判断。
能拿来做什么
- 先检查写数据库的路由,搜索来自
req.json()、JSON.parse、fetch的as SomeType,确认断言之前是否真正做过运行时校验。 - 搜索
Model.create(body)、Model.update(..., body)和写库参数中的{ ...body },把写入对象改为明确允许的字段或校验结果,并检查敏感字段的授权逻辑。 - 检查
Number(searchParams.get(...))是否处理NaN、空字符串和范围,再检查布尔查询参数是否把字符串'false'转成了true。 - 给 AI 编程助手补一条接口要求:JSON 解析失败返回 400,输入先按
unknown处理,校验成功后使用解析结果,并单独实现权限检查。审查生成代码时,沿这条数据路径核对。