本节目标:读完这一节,你能为自己的服务画出一张信任边界图,说清哪些数据必须解析、哪些可以信任;能区分「校验」与「解析」这两种截然不同的做法并说明为什么只有后者能真正消除类型风险;会用品牌类型把
unknown逐步收窄成领域类型;能识别原型污染、__proto__、深合并这三类攻击面,并用 lint 规则禁止as跨越边界。
10.3 边界数据与不可信输入
前两节我们各处理了一条边界:进来时用守卫与 schema 校验(10.1),写出再读回时用编解码器区分两种类型(10.2)。但有一个前提一直没有正面讨论:哪些数据是「不可信」的?
这个问题的答案决定了整个系统的类型安全水平。如果边界划得太宽,你会在无数地方重复校验;如果划得太窄,一个未被识别的入口就能击穿全部防线。
信任边界划在哪里
「不可信」不是指「恶意」,而是指其形状不由你控制。判定标准只有一条:这份数据的生产者是不是你的代码?
| 数据来源 | 可信? | 理由 |
|---|---|---|
| 函数参数(内部调用) | 是 | 调用方是类型检查过的代码 |
| 模块内的常量 | 是 | 编译期已知 |
| HTTP 请求 body / query / header | 否 | 任何人可构造 |
| 环境变量 | 否 | 可被注入,且总是 string |
| 第三方 API 响应 | 否 | 对方随时可改格式 |
| 数据库读出的行 | 否 | 列可空、可被历史数据违反 |
| 消息队列消息 | 否 | 可能是旧版本生产者写入 |
localStorage / Cookie | 否 | 用户可编辑 |
| 自己刚写入的缓存 | 否 | 缓存可能过期或被外部改写 |
JSON.parse 的结果 | 否 | 类型是 any,见 10.2 |
这张表最容易被忽略的是最后三行。 很多人认为「我从数据库读的数据肯定是干净的」——但数据库允许 NULL、允许历史迁移留下不符合当前约束的行、允许运维直接改数据。「我写的代码产生过它」不等于「现在读到的就是它」,中间隔着时间、版本和其他写入者。
一条实用的判断法:只要数据经过了「序列化 → 存储 → 反序列化」或「跨进程传输」,它就必须重新解析。 类型信息不会跟着数据走。
解析,而非校验(Parse, don’t validate)
这是边界设计的核心原则,两者的差别可以用签名直接看出来:
// 校验:返回 boolean,调用方拿到的东西类型没变
declare function validateUser(input: unknown): boolean;
// 解析:返回类型化结果,调用方拿到的东西类型变了
declare function parseUser(input: unknown): User;
差别不在实现,而在谁承担举证责任:
const data: unknown = await req.json();
if (validateUser(data)) {
// data 的类型仍然是 unknown
// data.name; ❌ Object is of type 'unknown'.
}
validateUser 返回 true 之后,data 的类型一点没变。调用方要么再做一次断言(把风险捡回来),要么改成 is 守卫。而 parseUser 直接给出 User:
const user = parseUser(await req.json());
user.name.toUpperCase(); // ✅ 类型确定
校验把「如何证明」的问题留给了每个调用点;解析在边界上一次性解决,此后全是普通代码。 这带来三个收益:
- 风险集中:所有不确定性被压缩在
parseUser一个函数里,可测试、可审计。 - 收窄不可逆:一旦变成
User,后续代码不需要任何as、!、?.的防御性写法。 - 失败语义明确:解析失败就抛错或返回
Result,边界入口据此直接返回 400,而不是带着脏数据继续跑。
把 unknown 变成领域类型:品牌类型
User 与 { name: string } 在结构类型系统里是同一个类型(见 1.1 结构化类型与兼容性判定
)。这意味着任何恰好有 name 字段的对象都能冒充 User——包括一个没经过解析的 as 结果。品牌类型可以堵上这个口子:
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<number, "UserId">;
type Email = Brand<string, "Email">;
// 唯一能构造出 Email 的地方
function parseEmail(input: unknown): Email {
if (typeof input !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input)) {
throw new ValidationError([`非法邮箱: ${String(input)}`]);
}
return input as Email; // 这里是唯一允许的断言,且被局部验证覆盖
}
declare const brand: unique symbol 是关键:它只存在于类型层,运行时不产生任何代码,因此品牌类型零开销。而 readonly [brand]: B 让 Email 无法被普通字符串满足:
const raw = "a@b.com";
sendEmail(raw); // ❌ Argument of type 'string' is not assignable to parameter of type 'Email'.
sendEmail(parseEmail(raw)); // ✅
注意最后那行 input as Email——它确实是一次断言,但它被前面的运行时判定完全覆盖,且位置唯一、可审计。这正是「断言可以有,但必须集中在边界」的具体形态。这比在 20 个调用点各写一次 as Email 安全得多。
品牌类型的代价是构造摩擦:解析过的值在传递中保持品牌,但任何重新组合(拼接、映射)都会丢失它。实践中的折中是只给最容易被混淆的标识类类型加品牌(ID、Email、URL、时间戳),而不是给每个字符串都加。
边界一:HTTP 请求
Web 框架给你的 body 通常是 any,query 与 header 是 Record<string, string | string[] | undefined>。三条纪律:
import { z } from "zod"; // 或 10.1 里手写的 Validator
const CreateOrder = z.object({
sku: z.string().min(1),
qty: z.number().int().positive(),
});
app.post("/orders", async (req, res) => {
const parsed = CreateOrder.safeParse(req.body); // body 是 any,但立刻被收口
if (!parsed.success) {
return res.status(400).json({ issues: parsed.error.issues });
}
const order = parsed.data; // 类型确定
res.json(await createOrder(order));
});
三条纪律是:
body在使用前必须解析,不要相信任何框架的自动解析声明。query全是字符串。?limit=10给你的是"10",z.coerce.number()这类显式转换是必需的,Number(x)会静默产生NaN。header的键名大小写与存在性都不可靠,取之前先归一化,且注意同名头可能重复。
一个高频错误是只在部分字段上校验:
const { id } = req.params; // 类型是 string,但可能是 "abc"
const num = Number(id); // NaN
await db.findUser(num); // 静默查不到,或查到意外结果
req.params 的 string 类型给了你虚假的安全感——它是字符串类型正确但内容非法。所有从路径、查询串来的「数字」都必须显式解析并验证。
边界二:环境变量与配置
process.env.X 的类型是 string | undefined,但真实项目里它经常是空字符串、"false" 或 "0":
function requireEnv(name: string): string {
const v = process.env[name];
if (v === undefined || v.trim() === "") {
throw new Error(`缺少必需的环境变量 ${name}`);
}
return v;
}
const PORT = (() => {
const n = Number(requireEnv("PORT"));
if (!Number.isInteger(n) || n <= 0 || n > 65535) throw new Error(`PORT 非法: ${n}`);
return n;
})();
const DEBUG = requireEnv("DEBUG") === "true"; // 注意:不是 Boolean("false") === true 的坑
最后一个坑值得展开。Boolean("false") 是 true,因为非空字符串都是真值:
console.log(Boolean("false")); // true ← 经典陷阱
console.log(Boolean("0")); // true
console.log(Boolean("")); // false
环境变量永远没有布尔类型,只有字符串。 要么显式比较 === "true",要么用 schema 的 coerce。并且配置解析应该在进程启动时一次性完成(fail-fast),而不是在第一次使用时——否则一个缺失的变量可能让服务跑上几天才在某条分支上崩掉。
边界三:第三方 API 响应
fetch 返回的 res.json() 类型是 Promise<any>,这是另一个必须立刻收口的地方:
const res = await fetch("https://api.partner.com/v1/user/42");
if (!res.ok) throw new Error(`上游失败: ${res.status}`);
const payload = PartnerUserSchema.safeParse(await res.json());
if (!payload.success) {
// 关键:上游格式变了要能快速发现,而不是静默降级
throw new UpstreamContractError(payload.error.issues);
}
const partner = payload.data;
这里的设计要点是把「上游契约违反」当成一种独立的错误类型。它和「网络失败」不同:网络失败可以重试,契约违反重试一万次也没用,而且它往往意味着你的代码需要更新。把它和业务错误混在一起,会让监控失去意义。
对于强契约的上游,更好的做法是用生成的类型替代手写:从 OpenAPI 文档或 GraphQL schema 生成 TypeScript 类型(见 7.1 .d.ts 生成与 exports 映射 里讲的生成管线),再配合运行时校验。生成类型解决的是「写代码时对不对」,运行时校验解决的是「跑起来时对方有没有骗你」——两者不可互相替代。
边界四:数据库读取
这是最容易被放过的一条。以 pg 为例,rows 的类型是 any[]:
// ❌ 把 any 直接当业务类型
const { rows } = await pool.query("SELECT id, name, deleted_at FROM users WHERE id = $1", [id]);
const user: User = rows[0];
// ✅ 显式声明行类型并解析
interface UserRow { id: number; name: string; deleted_at: Date | null }
const { rows } = await pool.query<UserRow>("SELECT ...", [id]);
const user = parseUserRow(rows[0]); // 处理 null、软删除、历史脏数据
泛型参数 query<UserRow> 只是你的声明,驱动不会验证它。列改名、加 NULL、迁移脚本写错,都不会有编译错误。因此数据库边界上同样需要解析,只是解析的内容偏「业务不变量」而非「形状」:
function parseUserRow(row: UserRow | undefined): User {
if (!row) throw new NotFoundError("user");
if (row.deleted_at !== null) throw new GoneError("user 已删除");
return { id: row.id, name: row.name.trim() || "匿名" };
}
关于 ORM 与查询构建器在类型安全上的能力边界,可以延伸阅读 TypeScript ORM 与数据访问 。
原型污染:__proto__ 与深合并
这是 JavaScript 独有的一类攻击面,而 TypeScript 的类型系统完全无法阻止它。原因是 JSON.parse 会创建名为 __proto__ 的自有属性(它用 [[DefineOwnProperty]] 语义,不触发 setter),但后续任何一次普通赋值都会触发原型链上的 setter:
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
console.log(Object.keys(payload)); // [ '__proto__' ] —— 是自有属性,不触发 setter
真正出问题的是递归深合并,几乎所有配置合并、默认值覆盖、部分更新的实现都长这样:
function merge(target: any, source: any): any {
for (const key in source) {
const val = source[key];
if (typeof val === "object" && val !== null) {
target[key] = merge(target[key] ?? {}, val);
} else {
target[key] = val;
}
}
return target;
}
const user = merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'));
console.log(user.isAdmin); // true ← 通过原型链读到
console.log({}.isAdmin); // undefined ← 尚未污染全局
上例污染的是 user 的原型,还算局部。但下面这个变体直接把 Object.prototype 改掉:
merge({}, JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}'));
console.log({}.isAdmin); // true ← 全进程所有对象都被污染
target.constructor 沿着原型链解析到 Object 构造函数,于是 merge 直接改写了 Object.prototype。此后任何对象(包括你没碰过的第三方库内部对象)都会「拥有」isAdmin: true——权限判断、特性开关、in 判定全部失效。
防御要三层一起上:
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge<T extends Record<string, unknown>>(target: T, source: unknown): T {
if (typeof source !== "object" || source === null) return target;
for (const key of Object.keys(source)) { // ① 只取自有可枚举键
if (UNSAFE_KEYS.has(key)) continue; // ② 拒绝危险键
const val = (source as Record<string, unknown>)[key];
if (typeof val === "object" && val !== null) {
const base = Object.hasOwn(target, key) ? (target as any)[key] : undefined;
const baseObj = typeof base === "object" && base !== null ? base : Object.create(null); // ③ 无原型容器
(target as any)[key] = safeMerge(baseObj, val);
} else {
(target as any)[key] = val;
}
}
return target;
}
三点缺一不可:用 Object.keys 而不是 for...in(后者会遍历原型链上的可枚举属性);黑名单危险键;递归容器用 Object.create(null)(没有原型就没有 setter 可触发)。另外判定自有属性请用 Object.hasOwn(或旧环境的 Object.prototype.hasOwnProperty.call),不要用 in——in 会查到原型链。
同样的原理适用于所有「按用户输入的键做索引」的地方:对象字典、lodash.set 风格的路径写入、模板变量替换、GraphQL 变量合并。这类问题的通用防线是在边界上拒绝非法键名,而不是在每个使用点防御。关于这类攻击的更多变体,可以延伸阅读 Web 安全:XSS 与 CSRF 防御
与 TypeScript 安全加固
。
禁止 as 跨越边界
前面所有原则落到执行层面,靠的是 lint。@typescript-eslint 里有一组专门针对 any 传播的规则,它们正是为边界设计的:
| 规则 | 拦截什么 |
|---|---|
no-unsafe-assignment | 把 any 赋给变量 |
no-unsafe-member-access | 在 any 上取属性 |
no-unsafe-call | 调用 any 类型的函数 |
no-unsafe-return | 从函数返回 any |
no-unsafe-argument | 把 any 当参数传给有类型的形参 |
consistent-type-assertions | 限制 as 的写法与位置 |
{
"rules": {
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error"
}
}
配置这三条后,JSON.parse 的结果一旦被使用就会报错,强迫你写解析。这是把「边界纪律」从口头约定变成 CI 门禁的最有效手段。
补充一条更直接的约定:禁止在 src/ 目录下出现 as,只允许在 src/boundary/ 或解析模块里出现,并配一条 lint 的 overrides 按目录放开。这比全局禁用更现实——边界上确实需要那一两次断言,而其余地方不该有。
边界检查清单
交付前可以逐条自检:
| 检查项 | 合格标准 |
|---|---|
| 每个入口都有解析 | body/query/header/env/上游/DB 全覆盖 |
| 解析返回类型而非布尔 | 不存在「校验完再 as」的写法 |
边界返回 unknown | 对外暴露的解析结果类型明确 |
| 数值显式转换 | 无裸 Number(x) 直接用 |
| 布尔显式比较 | 无 Boolean(env) |
| 危险键被拒绝 | 深合并有黑名单与自有键判定 |
| 配置启动时校验 | fail-fast,不在运行时第一次使用才炸 |
| lint 门禁生效 | no-unsafe-* 三件套为 error |
| 失败可观测 | 契约违反有独立错误类型与告警 |
常见坑与报错对照
| 现象 | 原因 | 处理 |
|---|---|---|
Object is of type 'unknown' | 未解析就使用 | 用 parse 而非 validate |
Number("abc") 得到 NaN | 查询串是字符串 | 用 coerce 并校验 |
Boolean("false") 为 true | 环境变量无布尔 | 显式 === "true" |
{}.isAdmin 莫名有值 | 原型污染 | 黑名单 + Object.create(null) |
| 上游改格式后静默出错 | 未校验上游响应 | 契约违反抛独立错误 |
DB 读出 null 崩溃 | 声明 UserRow 但列可空 | 行解析处理 null |
小结
这一节我们把「不可信输入」从一句口号变成了可执行的工程纪律:
- 信任边界只看生产者:凡经过序列化、跨进程、跨版本的数据都不可信,包括数据库与自己的缓存。
- 解析而非校验:
parse把unknown变成领域类型并把举证责任留在边界,validate只返回布尔、把问题推给每个调用点。 - 品牌类型:用
unique symbol让Email、UserId无法被裸字符串冒充,把断言压缩到唯一的构造点。 - 四条真实边界:HTTP(body/query/header 都要解析)、环境变量(全是字符串,启动时 fail-fast)、上游 API(契约违反要独立报错)、数据库(泛型参数只是声明,不构成验证)。
- 原型污染:
__proto__/constructor/prototype是必须拉黑的键,防线是「自有键遍历 + 黑名单 + 无原型容器」三层。 - lint 门禁:
no-unsafe-assignment等规则把边界纪律变成 CI 断言,比口头约定可靠。
第十章到这里就结束了。我们走完了运行时边界的三个面向:进来时用守卫与 schema 校验(10.1),写出再读回用编解码器区分两种类型(10.2),不可信输入用解析与信任边界把它们挡在领域之外(10.3)。三条线的共同结论只有一句:类型只在编译期存在,凡是跨越运行时的数据,都必须重新证明它的形状。
下一章我们换一个时间维度——不再是空间上的「边界」,而是版本上的「演进」。当 TypeScript 自身升级、当项目从宽松配置走向严格、当团队需要把类型纪律沉淀成规范时,会发生什么。想先看工程全景的读者,可以延伸阅读 类型优先开发 与 TypeScript 运行时校验与类型安全 。
阅读导航:上一节:10.2 序列化与反序列化类型 · 下一节:11.1 TS 版本演进与 breaking changes 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。