diff --git a/eslint/eslint-plugin/src/index.ts b/eslint/eslint-plugin/src/index.ts index 61f0c64f23e..4e1cd4729ab 100644 --- a/eslint/eslint-plugin/src/index.ts +++ b/eslint/eslint-plugin/src/index.ts @@ -8,6 +8,7 @@ import { noBackslashImportsRule } from './no-backslash-imports'; import { noExternalLocalImportsRule } from './no-external-local-imports'; import { noNewNullRule } from './no-new-null'; import { noNullRule } from './no-null'; +import { nullPrototypeDictionariesRule } from './null-prototype-dictionaries'; import { noTransitiveDependencyImportsRule } from './no-transitive-dependency-imports'; import { noUntypedUnderscoreRule } from './no-untyped-underscore'; import { normalizedImportsRule } from './normalized-imports'; @@ -36,6 +37,9 @@ const plugin: IPlugin = { // Full name: "@rushstack/no-null" 'no-null': noNullRule, + // Full name: "@rushstack/null-prototype-dictionaries" + 'null-prototype-dictionaries': nullPrototypeDictionariesRule, + // Full name: "@rushstack/no-transitive-dependency-imports" 'no-transitive-dependency-imports': noTransitiveDependencyImportsRule, diff --git a/eslint/eslint-plugin/src/no-null.ts b/eslint/eslint-plugin/src/no-null.ts index 3525d0128a4..acb531cfbca 100644 --- a/eslint/eslint-plugin/src/no-null.ts +++ b/eslint/eslint-plugin/src/no-null.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; type MessageIds = 'error-usage-of-null'; type Options = []; @@ -27,15 +28,43 @@ const noNullRule: TSESLint.RuleModule = { // Is it a "null" literal? if (node.value === null) { // Does the "null" appear in a comparison such as "if (x === null)"? - let isComparison: boolean = false; - if (node.parent && node.parent.type === 'BinaryExpression') { + if (node.parent && node.parent.type === AST_NODE_TYPES.BinaryExpression) { const operator: string = node.parent.operator; - isComparison = operator === '!==' || operator === '===' || operator === '!=' || operator === '=='; + if (operator === '!==' || operator === '===' || operator === '!=' || operator === '==') { + return; + } } - if (!isComparison) { - context.report({ node, messageId: 'error-usage-of-null' }); + // Is this "Object.create(null)"? This is the correct pattern for creating + // a dictionary object that does not inherit members from the Object prototype. + if ( + node.parent && + node.parent.type === AST_NODE_TYPES.CallExpression && + node.parent.arguments[0] === node && + node.parent.callee.type === AST_NODE_TYPES.MemberExpression && + node.parent.callee.object.type === AST_NODE_TYPES.Identifier && + node.parent.callee.object.name === 'Object' && + node.parent.callee.property.type === AST_NODE_TYPES.Identifier && + node.parent.callee.property.name === 'create' + ) { + return; } + + // Is this "__proto__: null" inside an object literal? This is used to create + // an object literal that does not inherit from Object.prototype. + if ( + node.parent && + node.parent.type === AST_NODE_TYPES.Property && + !node.parent.computed && + ((node.parent.key.type === AST_NODE_TYPES.Identifier && + node.parent.key.name === '__proto__') || + (node.parent.key.type === AST_NODE_TYPES.Literal && + node.parent.key.value === '__proto__')) + ) { + return; + } + + context.report({ node, messageId: 'error-usage-of-null' }); } } }; diff --git a/eslint/eslint-plugin/src/null-prototype-dictionaries.ts b/eslint/eslint-plugin/src/null-prototype-dictionaries.ts new file mode 100644 index 00000000000..6e8e9594206 --- /dev/null +++ b/eslint/eslint-plugin/src/null-prototype-dictionaries.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESTree, TSESLint, ParserServices } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +type MessageIds = 'error-empty-object-literal-dictionary'; +type Options = []; + +const nullPrototypeDictionariesRule: TSESLint.RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + 'error-empty-object-literal-dictionary': + 'Dictionary objects typed as Record should be created using Object.create(null)' + + ' instead of an empty object literal. This avoids prototype pollution, collisions with' + + ' Object.prototype members such as "toString", and enables higher performance since runtimes' + + ' such as V8 process Object.create(null) as opting out of having a hidden class and going' + + ' directly to dictionary mode.' + }, + schema: [], + docs: { + description: + 'Enforce that objects typed as string-keyed dictionaries (Record) are instantiated' + + ' using Object.create(null) instead of object literals, to avoid prototype pollution issues,' + + ' collisions with Object.prototype members such as "toString", and for higher performance' + + ' since runtimes such as V8 process Object.create(null) as opting out of having a hidden' + + ' class and going directly to dictionary mode', + recommended: 'strict', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } as TSESLint.RuleMetaDataDocs + }, + create: (context: TSESLint.RuleContext) => { + const parserServices: Partial | undefined = + context.sourceCode?.parserServices ?? context.parserServices; + if (!parserServices || !parserServices.program || !parserServices.esTreeNodeToTSNodeMap) { + throw new Error( + 'This rule requires your ESLint configuration to define the "parserOptions.project"' + + ' property for "@typescript-eslint/parser".' + ); + } + + const typeChecker: ts.TypeChecker = parserServices.program.getTypeChecker(); + + /** + * Checks whether the given type represents a pure string-keyed dictionary type: + * it has a string index signature and no named properties. + */ + function isStringKeyedDictionaryType(type: ts.Type): boolean { + // Check if the type has a string index signature + if (!type.getStringIndexType()) { + return false; + } + + // A pure dictionary type has no named properties - only an index signature. + // Types with named properties (like interfaces with extra index signatures) + // are not considered pure dictionaries. + if (type.getProperties().length > 0) { + return false; + } + + return true; + } + + return { + ObjectExpression(node: TSESTree.ObjectExpression): void { + const tsNode: ts.Node = parserServices.esTreeNodeToTSNodeMap!.get(node); + + // Get the contextual type (the type expected by the surrounding context, + // e.g. from a variable declaration's type annotation) + const contextualType: ts.Type | undefined = typeChecker.getContextualType( + tsNode as ts.Expression + ); + if (!contextualType) { + return; + } + + if (!isStringKeyedDictionaryType(contextualType)) { + return; + } + + // Only flag empty object literals; non-empty literals are allowed for now + if (node.properties.length === 0) { + context.report({ + node, + messageId: 'error-empty-object-literal-dictionary' + }); + } + } + }; + } +}; + +export { nullPrototypeDictionariesRule }; diff --git a/eslint/eslint-plugin/src/test/no-null.test.ts b/eslint/eslint-plugin/src/test/no-null.test.ts new file mode 100644 index 00000000000..61f0963dbee --- /dev/null +++ b/eslint/eslint-plugin/src/test/no-null.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RuleTester } from '@typescript-eslint/rule-tester'; + +import { getRuleTesterWithoutProject } from './ruleTester'; +import { noNullRule } from '../no-null'; + +const ruleTester: RuleTester = getRuleTesterWithoutProject(); + +ruleTester.run('no-null', noNullRule, { + invalid: [ + { + // Assigning null to a variable + code: 'let x = null;', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Passing null as a function argument (not Object.create) + code: 'foo(null);', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Returning null + code: 'function f() { return null; }', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Using null in a ternary + code: 'let x = true ? null : undefined;', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Using null as a second argument to Object.create + code: 'Object.create(proto, null);', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Using null on a different method of Object + code: 'Object.assign(null);', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Using null with a different object's create method + code: 'NotObject.create(null);', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // Computed property access: Object["create"](null) is NOT exempted + code: 'Object["create"](null);', + errors: [{ messageId: 'error-usage-of-null' }] + }, + { + // null on a non-__proto__ property is still flagged + code: 'const x = { foo: null };', + errors: [{ messageId: 'error-usage-of-null' }] + } + ], + valid: [ + { + // Comparison with === is allowed + code: 'if (x === null) {}' + }, + { + // Comparison with !== is allowed + code: 'if (x !== null) {}' + }, + { + // Comparison with == is allowed + code: 'if (x == null) {}' + }, + { + // Comparison with != is allowed + code: 'if (x != null) {}' + }, + { + // Object.create(null) is allowed for creating prototype-less dictionary objects + code: 'const dict = Object.create(null);' + }, + { + // Object.create(null) with type annotation + code: 'const dict: Record = Object.create(null);' + }, + { + // Object.create(null) inside a function + code: 'function createDict() { return Object.create(null); }' + }, + { + // __proto__: null is allowed in object literals + code: 'const obj = { __proto__: null, a: 1 };' + }, + { + // __proto__: null as string key is also allowed + code: 'const obj = { "__proto__": null, a: 1 };' + } + ] +}); diff --git a/eslint/eslint-plugin/src/test/null-prototype-dictionaries.test.ts b/eslint/eslint-plugin/src/test/null-prototype-dictionaries.test.ts new file mode 100644 index 00000000000..45e14b09c21 --- /dev/null +++ b/eslint/eslint-plugin/src/test/null-prototype-dictionaries.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RuleTester } from '@typescript-eslint/rule-tester'; + +import { getRuleTesterWithProject } from './ruleTester'; +import { nullPrototypeDictionariesRule } from '../null-prototype-dictionaries'; + +const ruleTester: RuleTester = getRuleTesterWithProject(); + +ruleTester.run('null-prototype-dictionaries', nullPrototypeDictionariesRule, { + invalid: [ + { + // Empty object literal assigned to Record + code: 'const dict: Record = {};', + errors: [{ messageId: 'error-empty-object-literal-dictionary' }] + }, + { + // Empty object literal assigned to index signature type + code: 'const dict: { [key: string]: number } = {};', + errors: [{ messageId: 'error-empty-object-literal-dictionary' }] + }, + { + // Reassignment to empty object literal + code: [ + 'let dict: Record;', + 'dict = {};' + ].join('\n'), + errors: [{ messageId: 'error-empty-object-literal-dictionary' }] + }, + { + // Return value from function + code: 'function f(): Record { return {}; }', + errors: [{ messageId: 'error-empty-object-literal-dictionary' }] + } + ], + valid: [ + { + // Correct pattern: Object.create(null) for empty dictionary + code: 'const dict: Record = Object.create(null);' + }, + { + // Non-empty object literal is allowed for now + code: 'const dict: Record = { a: "hello" };' + }, + { + // Non-empty literal with __proto__: null is also fine + code: 'const dict: Record = { __proto__: null, a: "hello" };' + }, + { + // Regular object type with named properties (not a dictionary) + code: 'const obj: { name: string } = { name: "hello" };' + }, + { + // No explicit dictionary type annotation + code: 'const obj = {};' + }, + { + // Record with literal union key type resolves to named properties, not a dictionary + code: 'const obj: Record<"a" | "b", number> = { a: 1, b: 2 };' + }, + { + // Interface with named properties AND index signature is not a pure dictionary + code: [ + 'interface IExtended { name: string; [key: string]: string }', + 'const obj: IExtended = { name: "hello" };' + ].join('\n') + }, + { + // Non-object-literal initializer is fine + code: [ + 'function getDict(): Record { return Object.create(null); }', + 'const dict: Record = getDict();' + ].join('\n') + } + ] +});