侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 123 篇文章
  • 累计收到 7 条评论

「__proto__」解析时是普通键,合并时才动原型

2026-9-25 / 0 评论 / 4 阅读

「__proto__」解析时是普通键,合并时才动原型

后端收到一份 JSON 请求体,先跟默认配置合并,再从里面取字段用。请求体里带一个 __proto__ 键,语法上合法,JSON.parse 也不会拦。同一份数据交给三种合并写法,结果分成三档。

① 刚解析完
   Object.keys       = ["__proto__","name"]
   parsed.polluted   = undefined
   原型还是 Object.prototype : true
② 展开复制 { ...parsed }
   spread.polluted   = undefined
   原型还是 Object.prototype : true
③ Object.assign({}, parsed)
   assigned.polluted = yes
   原型被换掉了 : true

第一段是解析完的对象:__proto__ 在这里是一个自有属性,Object.keys 能把它列出来,原型仍然是 Object.prototype,读 parsed.polluted 得到 undefined。解析这一步不动原型。

第二段是展开复制:{...parsed} 建出来的新对象原型没变,读字段也读不到污染值。

第三段不一样。Object.assign({}, parsed) 的结果里 assigned.polluted 是 yes,assigned 的原型已经不是 Object.prototype 了。

展开语法只做浅复制,嵌套对象仍然共用同一份内容,不过它和原型链无关,属于另外一类问题。三种写法里真正会动原型的是后两种,也就是「按普通赋值合并」和「递归合并」。

请求体的键名由客户端决定,服务端能控制的只有取哪些字段。这个不对称是后面所有挡法的出发点。

分岔在赋值那一步

展开语法和对象字面量定义属性时走的是「建一个自有属性」这条路,不查原型链上有没有同名属性。Object.assign 处理每个键用的是普通赋值,会先看目标对象上有没有同名属性、有没有访问器。__proto__ 在 Object.prototype 上刚好带一个访问器,赋值于是变成了换原型。

这段是按行为反推的结论,没有逐行对照规范原文;两种写法的差异在本机这几行输出里可以稳定复现,换一个环境再跑一次就能对上。

递归合并那一趟走得更远

④ 递归 merge 默认值与请求体
   合并前 ({}).polluted = undefined
   合并后 ({}).polluted = yes
   连空对象都被污染了 : true
⑤ 污染之后这个属性的样子
   'polluted' in {} : true
   JSON.stringify({}) = {}
   Object.keys({}) = []

merge({}, parsed) 遇到 __proto__ 这个键时,对应的值是对象,代码会往下递归。取 target['__proto__'] 取到的是目标的原型 Object.prototype,并非自有属性,接着就往原型上写字段,改动的范围超出了这一次请求。

污染之后的样子不好认:'polluted' in {} 是 true,Object.keys({}) 是空数组,JSON.stringify({}) 还是输出 {}。用键遍历看不到它,用 in 才能看到。

想确认有没有被污染,合并之后加一次断言最省事:Object.getPrototypeOf(target) === Object.prototype 为假,就说明这一次合并换过原型。逐个字段比对的写法要事先知道有哪些键,撞上原型链上的属性时才反应得过来,代价高得多。

三条挡法分别挡到哪一步

⑥ 只挑白名单字段
   picked = {"name":"甲"}
   有 __proto__ 这个自有属性吗 : false
   清理后 ({}).polluted = undefined
⑦ 用 reviver 在解析阶段丢掉 __proto__
   Object.keys      = ["name"]
   合并后 ({}).polluted = undefined
⑧ reviver 对嵌套层有没有用
   合并后 ({}).deep = undefined

白名单只从请求体里取需要的键,picked 里只剩 name,__proto__ 这个自有属性也不存在。挡得最死,代价是请求体字段变动时要跟着维护这份名单。

reviver 在解析阶段把这个键丢掉:JSON.parse(raw, (k, v) => (k === "__proto__" ? undefined : v))。它对嵌套层同样生效,⑧ 那一段里 ({}).deep 仍是 undefined,说明内层对象里的 __proto__ 也被拦下了。代价是它只管解析这一步,别的来源拼出来的中间对象管不到,而且它按每个键调用一次,返回 undefined 才代表删掉这个键,请求体越大开销越明显。

⑨ 用 Object.create(null) 当合并目标
   bare 的自有键    = ["__proto__","name"]
   ({}).polluted    = undefined
   bare['__proto__'] 是自有属性吗 : true

无原型对象做合并目标时,这个对象没有原型链,__proto__ 只能作为普通键写进去,自有键列表里能看到它,全局也没有被改动。代价是它拿不到 hasOwnProperty 这类方法,判断要走 Object.prototype.hasOwnProperty.call(bare, key)。

== 运行环境 ==
node v26.8.1 | V8 14.6.202.34-node.28

选择上没什么悬念:请求体按白名单取值,默认值只做兜底,不把整份请求体铺到配置对象上。我现在的做法是白名单加 reviver 两道,白名单挡业务字段,reviver 兜住那些没进白名单却仍会被合并的路径,比如从缓存里取出来再解析一次的旧 JSON。

要留意的是这类改动在开发环境里不会有症状,只有在某个键名撞上原型链上的属性时才露出来,且露出来的形式是「一个从没赋过的字段有值了」。

    🤞 分享