Skip to content

Commit 602a68b

Browse files
Add null-prototype-dictionaries rule enforcing Object.create(null) for Record types
Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/88c04cb4-f442-4fe2-8991-6a20aa3c6f9b Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
1 parent 50f5af3 commit 602a68b

3 files changed

Lines changed: 168 additions & 0 deletions

File tree

eslint/eslint-plugin/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { noBackslashImportsRule } from './no-backslash-imports';
88
import { noExternalLocalImportsRule } from './no-external-local-imports';
99
import { noNewNullRule } from './no-new-null';
1010
import { noNullRule } from './no-null';
11+
import { nullPrototypeDictionariesRule } from './null-prototype-dictionaries';
1112
import { noTransitiveDependencyImportsRule } from './no-transitive-dependency-imports';
1213
import { noUntypedUnderscoreRule } from './no-untyped-underscore';
1314
import { normalizedImportsRule } from './normalized-imports';
@@ -36,6 +37,9 @@ const plugin: IPlugin = {
3637
// Full name: "@rushstack/no-null"
3738
'no-null': noNullRule,
3839

40+
// Full name: "@rushstack/null-prototype-dictionaries"
41+
'null-prototype-dictionaries': nullPrototypeDictionariesRule,
42+
3943
// Full name: "@rushstack/no-transitive-dependency-imports"
4044
'no-transitive-dependency-imports': noTransitiveDependencyImportsRule,
4145

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { TSESTree, TSESLint, ParserServices } from '@typescript-eslint/utils';
5+
import type * as ts from 'typescript';
6+
7+
type MessageIds = 'error-object-literal-dictionary';
8+
type Options = [];
9+
10+
const nullPrototypeDictionariesRule: TSESLint.RuleModule<MessageIds, Options> = {
11+
defaultOptions: [],
12+
meta: {
13+
type: 'problem',
14+
messages: {
15+
'error-object-literal-dictionary':
16+
'Dictionary objects typed as Record<string, T> should be created using Object.create(null)' +
17+
' instead of an object literal. This avoids prototype pollution, collisions with' +
18+
' Object.prototype members such as "toString", and enables higher performance since runtimes' +
19+
' such as V8 process Object.create(null) as opting out of having a hidden class and going' +
20+
' directly to dictionary mode.'
21+
},
22+
schema: [],
23+
docs: {
24+
description:
25+
'Enforce that objects typed as string-keyed dictionaries (Record<string, T>) are instantiated' +
26+
' using Object.create(null) instead of object literals, to avoid prototype pollution issues,' +
27+
' collisions with Object.prototype members such as "toString", and for higher performance' +
28+
' since runtimes such as V8 process Object.create(null) as opting out of having a hidden' +
29+
' class and going directly to dictionary mode',
30+
recommended: 'strict',
31+
url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin'
32+
} as TSESLint.RuleMetaDataDocs
33+
},
34+
create: (context: TSESLint.RuleContext<MessageIds, Options>) => {
35+
const parserServices: Partial<ParserServices> | undefined =
36+
context.sourceCode?.parserServices ?? context.parserServices;
37+
if (!parserServices || !parserServices.program || !parserServices.esTreeNodeToTSNodeMap) {
38+
throw new Error(
39+
'This rule requires your ESLint configuration to define the "parserOptions.project"' +
40+
' property for "@typescript-eslint/parser".'
41+
);
42+
}
43+
44+
const typeChecker: ts.TypeChecker = parserServices.program.getTypeChecker();
45+
46+
/**
47+
* Checks whether the given type represents a pure string-keyed dictionary type:
48+
* it has a string index signature and no named properties.
49+
*/
50+
function isStringKeyedDictionaryType(type: ts.Type): boolean {
51+
// Check if the type has a string index signature
52+
if (!type.getStringIndexType()) {
53+
return false;
54+
}
55+
56+
// A pure dictionary type has no named properties - only an index signature.
57+
// Types with named properties (like interfaces with extra index signatures)
58+
// are not considered pure dictionaries.
59+
if (type.getProperties().length > 0) {
60+
return false;
61+
}
62+
63+
return true;
64+
}
65+
66+
return {
67+
ObjectExpression(node: TSESTree.ObjectExpression): void {
68+
const tsNode: ts.Node = parserServices.esTreeNodeToTSNodeMap!.get(node);
69+
70+
// Get the contextual type (the type expected by the surrounding context,
71+
// e.g. from a variable declaration's type annotation)
72+
const contextualType: ts.Type | undefined = typeChecker.getContextualType(
73+
tsNode as ts.Expression
74+
);
75+
if (!contextualType) {
76+
return;
77+
}
78+
79+
if (isStringKeyedDictionaryType(contextualType)) {
80+
context.report({
81+
node,
82+
messageId: 'error-object-literal-dictionary'
83+
});
84+
}
85+
}
86+
};
87+
}
88+
};
89+
90+
export { nullPrototypeDictionariesRule };
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { RuleTester } from '@typescript-eslint/rule-tester';
5+
6+
import { getRuleTesterWithProject } from './ruleTester';
7+
import { nullPrototypeDictionariesRule } from '../null-prototype-dictionaries';
8+
9+
const ruleTester: RuleTester = getRuleTesterWithProject();
10+
11+
ruleTester.run('null-prototype-dictionaries', nullPrototypeDictionariesRule, {
12+
invalid: [
13+
{
14+
// Empty object literal assigned to Record<string, number>
15+
code: 'const dict: Record<string, number> = {};',
16+
errors: [{ messageId: 'error-object-literal-dictionary' }]
17+
},
18+
{
19+
// Empty object literal assigned to index signature type
20+
code: 'const dict: { [key: string]: number } = {};',
21+
errors: [{ messageId: 'error-object-literal-dictionary' }]
22+
},
23+
{
24+
// Non-empty object literal assigned to Record type
25+
code: 'const dict: Record<string, string> = { a: "hello" };',
26+
errors: [{ messageId: 'error-object-literal-dictionary' }]
27+
},
28+
{
29+
// Reassignment to empty object literal
30+
code: [
31+
'let dict: Record<string, number>;',
32+
'dict = {};'
33+
].join('\n'),
34+
errors: [{ messageId: 'error-object-literal-dictionary' }]
35+
},
36+
{
37+
// Return value from function
38+
code: 'function f(): Record<string, number> { return {}; }',
39+
errors: [{ messageId: 'error-object-literal-dictionary' }]
40+
}
41+
],
42+
valid: [
43+
{
44+
// Correct pattern: Object.create(null) for dictionary
45+
code: 'const dict: Record<string, number> = Object.create(null);'
46+
},
47+
{
48+
// Regular object type with named properties (not a dictionary)
49+
code: 'const obj: { name: string } = { name: "hello" };'
50+
},
51+
{
52+
// No explicit dictionary type annotation
53+
code: 'const obj = {};'
54+
},
55+
{
56+
// Record with literal union key type resolves to named properties, not a dictionary
57+
code: 'const obj: Record<"a" | "b", number> = { a: 1, b: 2 };'
58+
},
59+
{
60+
// Interface with named properties AND index signature is not a pure dictionary
61+
code: [
62+
'interface IExtended { name: string; [key: string]: string }',
63+
'const obj: IExtended = { name: "hello" };'
64+
].join('\n')
65+
},
66+
{
67+
// Non-object-literal initializer is fine
68+
code: [
69+
'function getDict(): Record<string, number> { return Object.create(null); }',
70+
'const dict: Record<string, number> = getDict();'
71+
].join('\n')
72+
}
73+
]
74+
});

0 commit comments

Comments
 (0)