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

结论:先堵住两个入口——所有深合并与按路径赋值必须拒绝 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。