《TypeScript高级编程》10.3 边界数据与不可信输入

本节把不可信输入单独拎出来讲。先定义信任边界划在哪里,再讲透「解析而非校验」这条原则与品牌类型如何把 unknown 变成领域类型,然后逐一过 HTTP 请求、环境变量、第三方 API、数据库四条真实边界,最后深入原型污染与深合并的攻击面、禁止 as 跨边界的 lint 配置。读完你能为自己的服务画出信任边界图,并在每条边界上写出可测试、可穷尽的解析代码。

本节目标:读完这一节,你能为自己的服务画出一张信任边界图,说清哪些数据必须解析、哪些可以信任;能区分「校验」与「解析」这两种截然不同的做法并说明为什么只有后者能真正消除类型风险;会用品牌类型把 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));
});

三条纪律是:

  1. body 在使用前必须解析,不要相信任何框架的自动解析声明。
  2. query 全是字符串。?limit=10 给你的是 "10",z.coerce.number() 这类显式转换是必需的,Number(x) 会静默产生 NaN。
  3. 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes