JSON.parse 与 __proto__:解析、赋值和合并的边界
排查 JSON 工具时,很容易把“解析成功”和“后续处理安全”混为一谈。一个典型例子是名为 __proto__ 的键:JSON 文本里它可以是普通数据,但复制到另一个对象时,所用操作会影响结果。
本文使用合成数据说明这一差别,不访问外部服务,不修改全局原型。示例已在 Node.js v24.19.0 中实际运行,配套验证包含 23 项断言,全部通过。文章由 AI 辅助整理,行为结论同时对照了 ECMAScript 规范与 MDN 文档。
1. 先区分自有属性和原型
观察一个对象时,需要分别回答三个问题:这个键是否属于对象自身;对象的原型是谁;读取一个不存在的自有属性时,是否从原型上取到了值。
Object.hasOwn() 回答第一个问题,Object.getPrototypeOf() 回答第二个问题。单独打印对象,或者只看 obj.someKey 的值,很容易漏掉第三种情况。
const source = JSON.parse(
'{"__proto__":{"lensMarker":"from-data"},"size":3}'
);
console.log(Object.hasOwn(source, "__proto__")); // true
console.log(Object.getPrototypeOf(source) === Object.prototype); // true
console.log(source.lensMarker); // undefined
这里没有传入 reviver。运行结果说明:__proto__ 被保留为自有数据属性,嵌套对象仍然只是数据。此时直接读取 source.__proto__ 得到的是这个同名自有属性,不能据此断言它就是对象的实际原型。
ECMAScript 对 JSON 解析的处理与 JavaScript 对象字面量的部分特殊语义存在区别。因此,不宜把 JSON 文本直接替换成一个看起来相似的对象字面量来验证这个问题。
2. 普通赋值会经过另一条路径
接下来把同一份嵌套数据写入一个新的普通对象:
const assigned = {};
assigned["__proto__"] = source["__proto__"];
console.log(Object.hasOwn(assigned, "__proto__")); // false
console.log(Object.getPrototypeOf(assigned) === source["__proto__"]); // true
console.log(assigned.lensMarker); // from-data
console.log(Object.hasOwn(assigned, "lensMarker")); // false
console.log(Object.hasOwn(Object.prototype, "lensMarker")); // false
在本文所验证的默认运行环境中,普通对象继承了 Object.prototype.__proto__ 的 setter。给这个名字赋一个对象值,会改变目标对象的原型,而不是增加同名的自有数据属性。
本例的变化发生在 assigned 上,并没有给全局 Object.prototype 增加 lensMarker。这两个影响范围应分别验证,不能看到“原型变化”就写成“所有对象都已被污染”。如果运行时禁用了该旧式访问器,上述赋值路径也可能不同。
3. Object.assign 与对象展开不能简单互换
const merged = Object.assign({}, source);
const spread = { ...source };
console.log(Object.getPrototypeOf(merged) === source["__proto__"]); // true
console.log(Object.hasOwn(merged, "__proto__")); // false
console.log(Object.getPrototypeOf(spread) === Object.prototype); // true
console.log(Object.hasOwn(spread, "__proto__")); // true
console.log(spread.lensMarker); // undefined
规范中的 Object.assign 对目标执行属性设置,因此这里会经过继承的 setter。对象展开使用的数据属性创建过程不会以同样方式触发目标 setter,因而在这个例子中保留了同名数据键。
但对象展开是浅拷贝:spread["__proto__"] 与 source["__proto__"] 仍引用同一个嵌套对象。它也没有删除这个键。若下一步又执行 Object.assign({}, spread),本次验证中目标原型仍然会变化。一次复制的结果符合预期,不代表整个处理链都经过了校验。
这里的结论只针对 JSON 解析得到的普通数据对象。对于包含 getter、Proxy 等可执行行为的任意 JavaScript 对象,还需要另外审查读取源属性时的行为。
4. 用零原型字典承载动态键
如果工具确实需要保存用户输入的任意 JSON 键,可以考虑把内部字典创建为零原型对象:
const dictionary = Object.create(null);
for (const key of Object.keys(source)) {
dictionary[key] = source[key];
}
console.log(Object.getPrototypeOf(dictionary)); // null
console.log(Object.hasOwn(dictionary, "__proto__")); // true
console.log(dictionary.hasOwnProperty); // undefined
该对象没有从 Object.prototype 继承 setter,也没有继承 hasOwnProperty 等方法,所以判断自有属性应使用 Object.hasOwn(dictionary, key)。Object.keys() 只枚举自有的可枚举字符串键,适合这里的 JSON 数据。
零原型只描述当前这个对象。上述循环仍是浅拷贝,嵌套值不会自动变成零原型对象。实现递归转换时,数组、嵌套对象、深度上限和错误处理需要分别设计;把数据再次交给其他合并函数时,也要审查新的目标和写入方式。
5. 把边界写进验证
这次验证分别覆盖了解析后的键保留、普通赋值后的目标原型、Object.assign 的行为、对象展开的自有属性、零原型字典、浅拷贝引用和后续再次合并。每一步都同时检查原型与自有属性,并在最后确认全局原型未被修改。
对于 JSON 格式化工具,可以保留合法输入键,并给出后续使用风险提示。对于结构固定的业务配置,则应根据业务契约校验允许的字段、类型和默认值。两者需求不同,不宜用一个通用合并函数替代所有输入校验。
参考资料:ECMAScript Language Specification 的 JSON.parse / ParseJSON、Object.assign、CopyDataProperties 条目;MDN 的 JavaScript prototype pollution 与 Object.create() 文档。查阅日期:2026-10-29。
- 点赞
- 收藏
- 关注作者
评论(0)