Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,

Expand Down
39 changes: 34 additions & 5 deletions eslint/eslint-plugin/src/no-null.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -27,15 +28,43 @@ const noNullRule: TSESLint.RuleModule<MessageIds, Options> = {
// 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' });
}
}
};
Expand Down
95 changes: 95 additions & 0 deletions eslint/eslint-plugin/src/null-prototype-dictionaries.ts
Original file line number Diff line number Diff line change
@@ -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<MessageIds, Options> = {
defaultOptions: [],
meta: {
type: 'problem',
messages: {
'error-empty-object-literal-dictionary':
'Dictionary objects typed as Record<string, T> 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<string, T>) 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<MessageIds, Options>) => {
const parserServices: Partial<ParserServices> | 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 };
97 changes: 97 additions & 0 deletions eslint/eslint-plugin/src/test/no-null.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = 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 };'
}
]
});
Original file line number Diff line number Diff line change
@@ -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<string, number>
code: 'const dict: Record<string, number> = {};',
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<string, number>;',
'dict = {};'
].join('\n'),
errors: [{ messageId: 'error-empty-object-literal-dictionary' }]
},
{
// Return value from function
code: 'function f(): Record<string, number> { return {}; }',
errors: [{ messageId: 'error-empty-object-literal-dictionary' }]
}
],
valid: [
{
// Correct pattern: Object.create(null) for empty dictionary
code: 'const dict: Record<string, number> = Object.create(null);'
},
{
// Non-empty object literal is allowed for now
code: 'const dict: Record<string, string> = { a: "hello" };'
},
{
// Non-empty literal with __proto__: null is also fine
code: 'const dict: Record<string, string> = { __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<string, number> { return Object.create(null); }',
'const dict: Record<string, number> = getDict();'
].join('\n')
}
]
});
Loading