FlawOpen

flawopen.com/原型污染/Object.assign()

Object.assign() 能防止原型污染吗?

CWE-1321: Prototype Pollution参考页面
简答

并非完全安全。Object.assign() 通过赋值将源对象的自有可枚举属性复制到目标对象。如果源对象含有自有可枚举键 __proto__,赋值可能调用继承的 setter 并更改目标对象的原型。单次浅层赋值本身不会修改共享的 Object.prototype;递归合并代码可能造成更广泛的影响。

不安全 — assign 与递归合并
// A JSON own "__proto__" key reaches the inherited setter
const source = JSON.parse('{"__proto__":{"isAdmin":true}}');
const target = Object.assign({}, source);
// target's prototype changes; Object.prototype does not.

// Recursive merge can reach Object.prototype
function deepAssign(target, source) {
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object') {
      target[key] = target[key] || {};
      deepAssign(target[key], source[key]);
      // With key === '__proto__', target[key] can be
      // Object.prototype; recursion then writes into it.
    } else {
      Object.assign(target, { [key]: source[key] });
    }
  }
}
安全 — 浅拷贝用 spread;深合并需防护
// Spread creates an own data property; it does not invoke the setter.
const shallow = { ...userInput };

// Reject dangerous keys at every depth and use own data properties.
const blocked = new Set(['__proto__', 'constructor', 'prototype']);
function isRecord(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}
function copySafe(value) {
  if (Array.isArray(value)) return value.map(copySafe);
  return isRecord(value) ? mergeSafe(value) : value;
}
function mergeSafe(source, target = Object.create(null)) {
  if (!isRecord(source)) throw new TypeError('Expected a plain object');
  for (const key of Object.keys(source)) {
    if (blocked.has(key)) continue;
    const value = source[key];
    const current = Object.hasOwn(target, key) ? target[key] : undefined;
    const next = isRecord(value)
      ? mergeSafe(value, isRecord(current) ? current : Object.create(null))
      : copySafe(value);
    Object.defineProperty(target, key, {
      value: next, enumerable: true, writable: true, configurable: true
    });
  }
  return target;
}

Object.assign() 如何处理 __proto__ 键

Object.assign() 使用目标对象的常规属性赋值行为复制源对象的每个自有可枚举属性。如果从 JSON 解析的源对象具有自有 __proto__ 属性,而目标对象继承了旧式 setter,该 setter 会更改目标的 [[Prototype]]。此时不会创建字面名称为 __proto__ 的自有属性。

对象展开 ({ ...source }) 会在结果中创建数据属性;同一个源键会成为自有数据属性,不会调用继承的 setter。浅拷贝时这个差异很有用,但不能防御后续的递归合并。

递归合并如何污染共享原型

当不安全的递归代码沿攻击者控制的键遍历对象图并写入共享原型时,就会发生原型污染。常见模式会读取 target[key] 来查找嵌套对象。如果 key 是 __proto__,读取可能返回 Object.prototype;下一层递归写入后会影响全局普通对象。还需检查 constructor.prototype 等路径。

代码审查时,检查哪些操作使用攻击者控制的键读取或写入目标,确认是否会触发 setter,并确保每个递归层级都拒绝危险键。

常见问题

对象展开 ({ ...obj }) 也同样安全吗?

浅拷贝时安全:spread 会创建自有数据属性,不会调用继承的 setter。相比之下,Object.assign() 遇到可枚举源键 __proto__ 时可能更改目标原型。两种浅层操作本身都不会修改共享的 Object.prototype。

structuredClone() 呢?

structuredClone() 会创建独立副本,不会应用源对象的原型链。自有的 __proto__ 数据键会作为数据保留;之后的不安全合并仍可能特殊处理该键。

直接使用一个库可以吗?

使用持续维护且更新的合并库,并检查它在每个嵌套层级如何处理 __proto__、constructor 和 prototype。多个常用 merge 与 object-path 工具曾发布原型污染公告;只有修复后的版本包含补丁。

参考资料

另请参阅 JavaScript 原型污染:成因与防护 JSON.parse() 会导致原型污染吗? JSON.parse() 比 eval() 更安全吗? lodash.merge() 能防止原型污染吗?