JSON 工具箱

JSON 处理中的安全风险清单

结论:JSON.parse 本身很安全,危险的是它之后发生的事:深合并、按路径赋值、内联到 HTML、无限制的深度与体积。

进阶约 12 分钟发布于 2026-10-30更新于 2026-10-30
JSON 处理中的安全风险清单封面

结论:先堵住两个入口——所有深合并与按路径赋值必须拒绝 proto、prototype、constructor,请求体大小与嵌套深度必须在解析前就有硬限制;只盯着 JSON.parse 本身,等于把最危险的入口留在原地。

攻击者不需要发明新漏洞,只要在 JSON 里多写一个键。

一、原型污染:最常见也最容易被忽视

1.1 JSON.parse 本身是安全的

const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted);        // undefined
console.log({}.polluted);          // undefined

JSON.parse 会把 __proto__ 当成普通自有属性创建,不会改写原型链。所以「JSON.parse 导致原型污染」这个说法不准确。

1.2 危险的是后续的合并

/**
 * 不安全的深合并:直接把源对象的键写进目标对象。
 * @param target 目标对象
 * @param source 来源对象(可能来自用户 JSON)
 * @returns 合并后的对象
 */
function unsafeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] ?? {};
      unsafeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin);    // true —— 全站被污染

constructor.prototype 也是同类入口:

{ "constructor": { "prototype": { "isAdmin": true } } }

1.3 修复方式

/** 必须拒绝的键名 */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

/**
 * 安全深合并:拒绝原型相关键,只处理自有属性。
 * @param target 目标对象
 * @param source 来源对象
 * @returns 合并后的新对象
 */
function safeMerge(target, source) {
  const result = { ...target };

  for (const key of Object.keys(source)) {
    if (FORBIDDEN_KEYS.has(key)) {
      continue;
    }
    const sourceValue = source[key];
    const isPlainObject =
      typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);

    if (isPlainObject) {
      result[key] = safeMerge(target[key] ?? {}, sourceValue);
      continue;
    }

    // 用 defineProperty 避免 setter 触发原型修改
    Object.defineProperty(result, key, {
      value: sourceValue,
      enumerable: true,
      writable: true,
      configurable: true,
    });
  }

  return result;
}

更彻底的做法是用 Object.create(null) 作为容器(没有原型链可污染),或直接用 Map。 使用现成库时,确认它已处理这三个键——很多老版本 lodash 的 merge 曾因此被通报。

1.4 按路径赋值同样危险

// 反例:路径来自用户输入
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

当 pathSegments 里出现 __proto__ 时,等同于上面的污染。JSON Pointer / JSON Patch 的实现必须校验每一段令牌。

二、资源耗尽型攻击

攻击 手段 防护
深层嵌套 十万层 [[[[...]]]] 限制嵌套深度(通常 32~64),解析前预扫
超大数组 千万个元素的数组 限制请求体大小、限制数组长度
超长字符串 单个字符串几百 MB 限制字段长度
重复键爆炸 大量重复键 限制对象键数量

服务端最有效的两道防线是请求体大小限制与深度限制,两者都要在任何解析之前生效。

/**
 * 在解析前粗查嵌套深度,防止解析阶段栈溢出。
 * @param text JSON 文本
 * @param maxDepth 允许的最大深度
 * @returns 是否在允许范围内
 */
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
  let currentDepth = 0;
  let isInsideString = false;
  let isEscaped = false;

  for (const character of text) {
    if (isInsideString) {
      if (isEscaped) {
        isEscaped = false;
      } else if (character === '\\') {
        isEscaped = true;
      } else if (character === '"') {
        isInsideString = false;
      }
      continue;
    }

    if (character === '"') {
      isInsideString = true;
    } else if (character === '{' || character === '[') {
      currentDepth += 1;
      if (currentDepth > maxDepth) {
        return false;
      }
    } else if (character === '}' || character === ']') {
      currentDepth -= 1;
    }
  }

  return true;
}

三、内联到 HTML 时的注入

把 JSON 直接写进页面是常见做法,但它引入三类问题:

<script>
  window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>

字符串里的 </script> 会提前闭合脚本块,后面的内容被当作 HTML 解析——典型的 XSS。

另外两个字符:\u2028(行分隔符)与 \u2029(段分隔符)在 JSON 里合法,但嵌进 JS 源码会触发语法错误。

修复:

/**
 * 把 JSON 安全地序列化进内联脚本。
 * @param value 数据对象
 * @returns 可安全放入 <script> 的字符串
 */
function serializeForInlineScript(value: unknown): string {
  return JSON.stringify(value)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

更稳的方案是把数据放在 <script type="application/json"> 里(内容不执行),再用 JSON.parse(document.getElementById('data').textContent) 读取。

四、JSON 劫持与 JSONP

早期浏览器允许跨域 <script src="https://api.example.com/user"> 直接执行返回的 JavaScript, 而顶层是数组的 JSON 恰好也是合法 JS 表达式,攻击者可以通过重写 Array 构造函数读取数据。

现代浏览器已有防护,但仍建议:

  • 接口顶层使用对象而非数组({"data": [...]});
  • 响应头带 X-Content-Type-Options: nosniff,并严格设置 Content-Type: application/json;
  • 不再使用 JSONP;确有跨域需求用 CORS。

五、反序列化到对象的类型混淆

Java 生态的 Jackson 开启默认类型(enableDefaultTyping)后,攻击者可以指定 @class 字段让服务端实例化任意类, 配合可利用的 gadget 链达成远程代码执行。这类漏洞历史上多次出现(Fastjson、Jackson 都中过招)。

原则:反序列化的目标类型必须由服务端代码静态指定,绝不允许由报文内容决定。

六、正则与 schema 的拒绝服务

  • JSON Schema 的 pattern、JSONPath 的 match() 都接受正则;
  • (a+)+$ 这类写法在长输入上会指数回溯;
  • 若正则来自用户(自定义校验规则),必须限制长度、禁用回溯引用与嵌套量词,或改用 RE2 这类线性引擎。

七、日志与脱敏

JSON 日志最容易泄露敏感信息:

/** 日志中必须脱敏的字段名(小写匹配) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);

/**
 * 递归脱敏对象中的敏感字段,用于日志输出。
 * @param value 原始值
 * @returns 脱敏后的副本
 */
function maskSensitive(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map((item) => maskSensitive(item));
  }
  if (typeof value === 'object' && value !== null) {
    return Object.fromEntries(
      Object.entries(value).map(([entryKey, entryValue]) => {
        if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
          return [entryKey, '***'];
        }
        return [entryKey, maskSensitive(entryValue)];
      }),
    );
  }
  return value;
}

注意脱敏要在写日志之前做,而不是事后清理日志平台。

八、上线前检查清单

  • [ ] 所有深合并函数拒绝 __proto__ / prototype / constructor;
  • [ ] JSON Pointer / Patch 的路径令牌做白名单校验;
  • [ ] 请求体大小与嵌套深度有硬限制,且在解析前生效;
  • [ ] 内联到 HTML 的 JSON 做了 <、>、&、U+2028、U+2029 转义;
  • [ ] 接口顶层使用对象,响应头带 nosniff;
  • [ ] 反序列化目标类型由代码静态指定;
  • [ ] 用户可控的正则有复杂度限制;
  • [ ] 日志字段有脱敏规则并做了测试。

常见问题

如何快速确认自己的深合并函数有没有原型污染入口?

把 JSON.parse('{"__proto__": {"isAdmin": true}}') 的结果喂给合并函数,随后检查 {}.isAdmin 是否变成 true;再把 constructor.prototype 那条也试一遍。用现成库时,确认它是否拒绝 __proto__、prototype、constructor 三个键——很多老版本 lodash 的 merge 曾因此被通报。按路径赋值的实现要单独检查,路径令牌里出现 __proto__ 时同样会中招。

为什么 JSON.parse 本身不会造成原型污染?

它会把 __proto__ 当成普通自有属性创建,不会改写原型链,所以 data.polluted 和 {}.polluted 都是 undefined。污染发生在解析之后:不安全的深合并把来源对象的键直接写进目标对象,constructor.prototype 是同类入口。把「JSON.parse 导致原型污染」当成根因,会修错地方。

内联 JSON 到 HTML 时是否必须转义?

必须。字符串里的 </script> 会提前闭合脚本块,后面的内容被当作 HTML 解析,这是典型的 XSS;<、>、& 与 U+2028、U+2029 也要一并转义。更稳的方案是把数据放在 <script type="application/json"> 里(内容不执行),再用 JSON.parse 读取 textContent。