From e601d34e0005028d3972c254e2c38ebcf9458157 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Mon, 12 Oct 2026 13:08:25 +0000 Subject: [PATCH] tools: update prototype-pollution rule to accept null proto obj Allow for... ```js const kNullProtoObject = Object.freeze({ __proto__: null }); const obj = { __proto__: kNullProtoObject, ... }; ``` Signed-off-by: James M Snell --- .../test-eslint-avoid-prototype-pollution.js | 57 ++++++++ .../eslint-rules/avoid-prototype-pollution.js | 132 ++++++++++++++---- 2 files changed, 165 insertions(+), 24 deletions(-) diff --git a/test/parallel/test-eslint-avoid-prototype-pollution.js b/test/parallel/test-eslint-avoid-prototype-pollution.js index 98dc951a9064..c41d077f55b4 100644 --- a/test/parallel/test-eslint-avoid-prototype-pollution.js +++ b/test/parallel/test-eslint-avoid-prototype-pollution.js @@ -75,6 +75,19 @@ new RuleTester() 'function name({ [Symbol.match]: m } = kEmptyObject){}', 'function name({ [Symbol.match]: m } = { __proto__: null, [Symbol.match]: 1 }){}', 'function name({ [Symbol.match]: m, c } = { __proto__: null, c: 1 }){}', + 'const kNull = ObjectFreeze({ __proto__: null }); ObjectDefineProperty({}, "key", { __proto__: kNull })', + 'const kNull = ObjectFreeze({ __proto__: null }); ReflectDefineProperty({}, "key", { __proto__: kNull })', + 'const kNull = ObjectFreeze({ "__proto__": null }); ObjectDefineProperties({}, { key: { __proto__: kNull } })', + 'const kNull = ObjectFreeze(ObjectCreate(null)); ObjectCreate(null, { key: { __proto__: kNull, value: 1 } })', + 'const kNull = ObjectFreeze({ __proto__: null });' + + 'function f() { ObjectDefineProperty({}, "k", { __proto__: kNull }) }', + 'const kA = ObjectFreeze({ __proto__: null }); const kB = kA; ObjectDefineProperty({}, "k", { __proto__: kB })', + 'const kA = ObjectFreeze({ __proto__: null }); const kB = ObjectFreeze({ __proto__: kA });' + + 'ObjectDefineProperty({}, "k", { __proto__: kB })', + 'const { kEmptyObject } = require("internal/util"); ObjectDefineProperty({}, "k", { __proto__: kEmptyObject })', + 'const { kEmptyObject: kE } = require("internal/util"); ObjectDefineProperty({}, "k", { __proto__: kE })', + 'const kNull = ObjectFreeze({ __proto__: null }); async function myFn() { return { __proto__: kNull } }', + 'const kNull = ObjectFreeze({ __proto__: null }); new Proxy({}, { __proto__: kNull })', ], invalid: [ { @@ -200,6 +213,50 @@ new RuleTester() code: 'ReflectDefineProperty({}, "key", { enumerable: true })', errors: [{ message: /null-prototype/ }], }, + { + code: 'ObjectDefineProperty({}, "key", { __proto__: kUnknown })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kNull = { __proto__: null }; ObjectDefineProperty({}, "key", { __proto__: kNull })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'let kNull = ObjectFreeze({ __proto__: null }); ObjectDefineProperty({}, "key", { __proto__: kNull })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kObj = ObjectFreeze({}); ObjectDefineProperty({}, "key", { __proto__: kObj })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kObj = ObjectFreeze({ __proto__: ObjectPrototype });' + + 'ObjectDefineProperty({}, "key", { __proto__: kObj })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kObj = ObjectFreeze(ObjectCreate(ObjectPrototype));' + + 'ObjectDefineProperty({}, "key", { __proto__: kObj })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kNull = ObjectFreeze({ __proto__: null });' + + 'function f(kNull) { ObjectDefineProperty({}, "key", { __proto__: kNull }) }', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const { kEmptyObject } = require("internal/other");' + + 'ObjectDefineProperty({}, "k", { __proto__: kEmptyObject })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const { other } = require("internal/util"); ObjectDefineProperty({}, "k", { __proto__: other })', + errors: [{ message: /null-prototype/ }], + }, + { + code: 'const kObj = ObjectFreeze({}); new Proxy({}, { __proto__: kObj })', + errors: [{ message: /null-prototype/ }], + }, { code: 'async function myFn(){ return {} }', errors: [{ message: /null-prototype/ }], diff --git a/tools/eslint-rules/avoid-prototype-pollution.js b/tools/eslint-rules/avoid-prototype-pollution.js index 79468a000274..b02b7652be55 100644 --- a/tools/eslint-rules/avoid-prototype-pollution.js +++ b/tools/eslint-rules/avoid-prototype-pollution.js @@ -16,28 +16,121 @@ function checkProperties(context, node) { } if (node.type !== 'ObjectExpression') return; for (const { key, value } of node.properties) { - if ( - key != null && value != null && - !(key.type === 'Identifier' && key.name === '__proto__') && - !(key.type === 'Literal' && key.value === '__proto__') - ) { + if (key != null && value != null && !isProtoKey(key)) { checkPropertyDescriptor(context, value); } } } -function isNullPrototypeObjectExpression(node) { - if (node.type !== 'ObjectExpression') return; +function isProtoKey(key) { + return key != null && + ((key.type === 'Identifier' && key.name === '__proto__') || + (key.type === 'Literal' && key.value === '__proto__')); +} - for (const { key, value } of node.properties) { +function findVariable(context, identifier) { + let scope = context.sourceCode.getScope(identifier); + while (scope != null) { + const variable = scope.set.get(identifier.name); + if (variable != null) return variable; + scope = scope.upper; + } + return null; +} + +// Frozen null-prototype objects exported from internal modules that can be +// safely used as the prototype of an object literal. +const knownNullPrototypeExports = { + 'internal/util': ['kEmptyObject'], +}; + +function isRequireOfKnownNullPrototypeExport(declarator, name) { + const { id, init } = declarator; + if ( + id.type !== 'ObjectPattern' || + init == null || + init.type !== 'CallExpression' || + init.callee.type !== 'Identifier' || + init.callee.name !== 'require' || + init.arguments.length !== 1 || + init.arguments[0].type !== 'Literal' + ) { + return false; + } + const allowed = knownNullPrototypeExports[init.arguments[0].value]; + if (allowed == null) return false; + return id.properties.some((property) => + property.type === 'Property' && + !property.computed && + property.key.type === 'Identifier' && + allowed.includes(property.key.name) && + property.value.type === 'Identifier' && + property.value.name === name, + ); +} + +// Returns true if `node` is an expression that is known to evaluate to a +// frozen object whose prototype is null, e.g.: +// const kNullProtoObject = ObjectFreeze({ __proto__: null }); +// Using such an object as the `__proto__` of an object literal is equivalent +// to `__proto__: null` in terms of protection against prototype pollution, but +// avoids V8 creating the object literal in dictionary mode. +function isFrozenNullPrototypeObject(context, node, seen = new Set()) { + if (node.type === 'Identifier') { + const variable = findVariable(context, node); + if (variable == null || seen.has(variable) || variable.defs.length !== 1) { + return false; + } + seen.add(variable); + const [def] = variable.defs; if ( - key != null && value != null && - ((key.type === 'Identifier' && key.name === '__proto__') || - (key.type === 'Literal' && key.value === '__proto__')) && - value.type === 'Literal' && value.value === null + def.type !== 'Variable' || + def.parent.kind !== 'const' || + def.node.init == null + ) { + return false; + } + if (def.node.id.type === 'Identifier') { + return isFrozenNullPrototypeObject(context, def.node.init, seen); + } + return isRequireOfKnownNullPrototypeExport(def.node, node.name); + } + + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'ObjectFreeze' && + node.arguments.length === 1 + ) { + const [arg] = node.arguments; + if ( + arg.type === 'CallExpression' && + arg.callee.type === 'Identifier' && + arg.callee.name === 'ObjectCreate' && + arg.arguments.length === 1 && + arg.arguments[0].type === 'Literal' && + arg.arguments[0].value === null ) { return true; } + return isNullPrototypeObjectExpression(context, arg, seen); + } + + return false; +} + +function isNullPrototypeObjectExpression(context, node, seen) { + if (node.type !== 'ObjectExpression') return; + + for (const { key, value } of node.properties) { + if (isProtoKey(key) && value != null) { + if (value.type === 'Literal' && value.value === null) { + return true; + } + if (isFrozenNullPrototypeObject(context, value, seen)) { + return true; + } + } } return false; } @@ -63,7 +156,7 @@ function checkPropertyDescriptor(context, node) { }], }); } - if (isNullPrototypeObjectExpression(node) === false) { + if (isNullPrototypeObjectExpression(context, node) === false) { context.report({ node, message: 'Must use null-prototype object for property descriptors', @@ -133,7 +226,7 @@ module.exports = { if (!parent.async) return; } - if (isNullPrototypeObjectExpression(node) === false) { + if (isNullPrototypeObjectExpression(context, node) === false) { context.report({ node, message: 'Use null-prototype when returning from async function', @@ -178,16 +271,7 @@ module.exports = { ...createUnsafeStringMethodOnRegexReport(context, '%String.prototype.split%', 'Symbol.split'), 'NewExpression[callee.name="Proxy"][arguments.1.type="ObjectExpression"]'(node) { - for (const { key, value } of node.arguments[1].properties) { - if ( - key != null && value != null && - ((key.type === 'Identifier' && key.name === '__proto__') || - (key.type === 'Literal' && key.value === '__proto__')) && - value.type === 'Literal' && value.value === null - ) { - return; - } - } + if (isNullPrototypeObjectExpression(context, node.arguments[1])) return; context.report({ node, message: 'Proxy handler must be a null-prototype object',