@lipemat/eslint-config 4.0.5 → 5.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/helpers/config.js +11 -11
- package/index.js +111 -115
- package/package.json +22 -7
- package/plugins/security/index.js +51 -0
- package/plugins/security/rules/dangerously-set-inner-html.js +62 -0
- package/plugins/security/rules/html-executing-assignment.js +67 -0
- package/plugins/security/rules/html-executing-function.js +130 -0
- package/plugins/security/rules/html-sinks.js +122 -0
- package/plugins/security/rules/html-string-concat.js +63 -0
- package/plugins/security/rules/jquery-executing.js +105 -0
- package/plugins/security/rules/vulnerable-tag-stripping.js +76 -0
- package/plugins/security/rules/window-escaping.js +180 -0
- package/plugins/security/utils/shared.js +78 -0
- package/types/helpers/config.d.ts +22 -0
- package/types/index.d.ts +3 -0
- package/types/plugins/security/index.d.ts +8 -0
- package/types/plugins/security/rules/dangerously-set-inner-html.d.ts +3 -0
- package/types/plugins/security/rules/html-executing-assignment.d.ts +4 -0
- package/types/plugins/security/rules/html-executing-function.d.ts +6 -0
- package/types/plugins/security/rules/html-sinks.d.ts +4 -0
- package/types/plugins/security/rules/html-string-concat.d.ts +9 -0
- package/types/plugins/security/rules/jquery-executing.d.ts +17 -0
- package/types/plugins/security/rules/vulnerable-tag-stripping.d.ts +4 -0
- package/types/plugins/security/rules/window-escaping.d.ts +5 -0
- package/types/plugins/security/utils/shared.d.ts +39 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
import { isLiteralString, isSanitized, isStringLike } from '../utils/shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Get the callee name from a call expression
|
|
5
|
+
*/
|
|
6
|
+
function getCalleeName(node) {
|
|
7
|
+
if (AST_NODE_TYPES.Identifier === node.callee.type) {
|
|
8
|
+
return node.callee.name;
|
|
9
|
+
}
|
|
10
|
+
else if (AST_NODE_TYPES.MemberExpression === node.callee.type) {
|
|
11
|
+
if ('name' in node.callee.object && 'name' in node.callee.property) {
|
|
12
|
+
return `${node.callee.object.name}.${node.callee.property.name}`;
|
|
13
|
+
}
|
|
14
|
+
else if ('name' in node.callee.property) {
|
|
15
|
+
return node.callee.property.name;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Check if the assignment is to body.style.cssText
|
|
22
|
+
*/
|
|
23
|
+
function isCssTextAssignment(node) {
|
|
24
|
+
if (AST_NODE_TYPES.MemberExpression !== node.left.type) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const memberExpr = node.left;
|
|
28
|
+
if (AST_NODE_TYPES.MemberExpression !== memberExpr.object.type || !('name' in memberExpr.property)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const parentMember = memberExpr.object;
|
|
32
|
+
if (!('name' in parentMember.object) || !('name' in parentMember.property)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return ('body' === parentMember.object.name &&
|
|
36
|
+
'style' === parentMember.property.name &&
|
|
37
|
+
'cssText' === memberExpr.property.name);
|
|
38
|
+
}
|
|
39
|
+
const plugin = {
|
|
40
|
+
defaultOptions: [],
|
|
41
|
+
meta: {
|
|
42
|
+
type: 'problem',
|
|
43
|
+
docs: {
|
|
44
|
+
description: 'Detect security issues with HTML sinks: setTimeout/setInterval with strings, unsanitized window.open, and unsanitized body.style.cssText',
|
|
45
|
+
},
|
|
46
|
+
messages: {
|
|
47
|
+
setTimeoutString: 'setTimeout should not receive a string. Pass a function instead.',
|
|
48
|
+
setIntervalString: 'setInterval should not receive a string. Pass a function instead.',
|
|
49
|
+
windowOpenUnsanitized: 'window.open should be sanitized to prevent XSS attacks.',
|
|
50
|
+
cssTextUnsanitized: 'body.style.cssText should be sanitized when not a literal string to prevent XSS attacks.',
|
|
51
|
+
sanitize: 'Wrap with sanitize(...)',
|
|
52
|
+
domPurify: 'Wrap with DOMPurify.sanitize(...)',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
hasSuggestions: true,
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
return {
|
|
59
|
+
CallExpression(node) {
|
|
60
|
+
const calleeName = getCalleeName(node);
|
|
61
|
+
// Handle setTimeout and setInterval with string arguments (not allowed)
|
|
62
|
+
if ('setTimeout' === calleeName || 'setInterval' === calleeName) {
|
|
63
|
+
const firstArg = node.arguments[0];
|
|
64
|
+
if (isStringLike(firstArg, context)) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'setTimeout' === calleeName ? 'setTimeoutString' : 'setIntervalString',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Handle window.open with string arguments (must be sanitized)
|
|
73
|
+
if ('window.open' === calleeName) {
|
|
74
|
+
const firstArg = node.arguments[0];
|
|
75
|
+
if (!isSanitized(firstArg)) {
|
|
76
|
+
const sourceCode = context.sourceCode;
|
|
77
|
+
const argText = sourceCode.getText(firstArg);
|
|
78
|
+
context.report({
|
|
79
|
+
node,
|
|
80
|
+
messageId: 'windowOpenUnsanitized',
|
|
81
|
+
suggest: [
|
|
82
|
+
{
|
|
83
|
+
messageId: 'domPurify',
|
|
84
|
+
fix: fixer => fixer.replaceText(firstArg, `DOMPurify.sanitize( ${argText} )`),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
messageId: 'sanitize',
|
|
88
|
+
fix: fixer => fixer.replaceText(firstArg, `sanitize( ${argText} )`),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
AssignmentExpression(node) {
|
|
96
|
+
// Handle body.style.cssText assignments
|
|
97
|
+
if (isCssTextAssignment(node)) {
|
|
98
|
+
// Allow literal strings but require sanitization for other values
|
|
99
|
+
if (!isLiteralString(node.right) && !isSanitized(node.right)) {
|
|
100
|
+
const sourceCode = context.sourceCode;
|
|
101
|
+
const rightText = sourceCode.getText(node.right);
|
|
102
|
+
context.report({
|
|
103
|
+
node,
|
|
104
|
+
messageId: 'cssTextUnsanitized',
|
|
105
|
+
suggest: [
|
|
106
|
+
{
|
|
107
|
+
messageId: 'domPurify',
|
|
108
|
+
fix: fixer => fixer.replaceText(node.right, `DOMPurify.sanitize( ${rightText} )`),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
messageId: 'sanitize',
|
|
112
|
+
fix: fixer => fixer.replaceText(node.right, `sanitize( ${rightText} )`),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
export default plugin;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
function isStringConcat(node) {
|
|
3
|
+
// 'foo' + userInput + 'bar' (HTML-like only)
|
|
4
|
+
return AST_NODE_TYPES.BinaryExpression === node.type && '+' === node.operator && hasHtmlLikeStrings(node);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Check if an expression contains any HTML-like strings.
|
|
8
|
+
* - Looks for `<` or `>` characters in string literals and template literals.
|
|
9
|
+
* - Recursively checks binary expressions with the ` + ` operator.
|
|
10
|
+
*/
|
|
11
|
+
export function hasHtmlLikeStrings(node) {
|
|
12
|
+
if (AST_NODE_TYPES.Literal === node.type && 'string' === typeof node.value) {
|
|
13
|
+
return /[<>]/.test(node.value);
|
|
14
|
+
}
|
|
15
|
+
if (AST_NODE_TYPES.TemplateLiteral === node.type) {
|
|
16
|
+
// Check any static part of the template for HTML-like content.
|
|
17
|
+
return node.quasis.some(q => /[<>]/.test(q.value.cooked));
|
|
18
|
+
}
|
|
19
|
+
if (AST_NODE_TYPES.BinaryExpression === node.type && '+' === node.operator) {
|
|
20
|
+
return hasHtmlLikeStrings(node.left) || hasHtmlLikeStrings(node.right);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const plugin = {
|
|
25
|
+
defaultOptions: [],
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'problem',
|
|
28
|
+
docs: {
|
|
29
|
+
description: 'Disallow string concatenation with HTML-like content',
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
htmlStringConcat: 'HTML string concatenation detected, this is a security risk, use DOM node construction or a templating language instead.',
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
return {
|
|
38
|
+
AssignmentExpression(node) {
|
|
39
|
+
const right = node.right;
|
|
40
|
+
if (isStringConcat(right)) {
|
|
41
|
+
context.report({
|
|
42
|
+
node,
|
|
43
|
+
messageId: 'htmlStringConcat',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
VariableDeclarator(node) {
|
|
48
|
+
// Detect string concatenation assigned at declaration time
|
|
49
|
+
const init = node.init;
|
|
50
|
+
if (null === init) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (isStringConcat(init)) {
|
|
54
|
+
context.report({
|
|
55
|
+
node,
|
|
56
|
+
messageId: 'htmlStringConcat',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export default plugin;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
import { getType, isSanitized } from '../utils/shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* @link https://docs.wpvip.com/security/javascript-security-recommendations/#h-stripping-tags
|
|
5
|
+
*/
|
|
6
|
+
const JQUERY_METHODS = [
|
|
7
|
+
'after', 'append', 'appendTo', 'before', 'html',
|
|
8
|
+
'insertAfter', 'insertBefore', 'prepend', 'prependTo',
|
|
9
|
+
'replaceAll', 'replaceWith',
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Is the type of variable being passed a jQuery element?
|
|
13
|
+
*
|
|
14
|
+
* - jQuery elements are of type `JQuery`.
|
|
15
|
+
* - jQuery elements do not require sanitization.
|
|
16
|
+
*
|
|
17
|
+
* @link https://typescript-eslint.io/developers/custom-rules/#typed-rules
|
|
18
|
+
*/
|
|
19
|
+
export function isJQueryElementType(arg, context) {
|
|
20
|
+
const element = getType(arg, context);
|
|
21
|
+
return 'JQuery' === element.getSymbol()?.escapedName;
|
|
22
|
+
}
|
|
23
|
+
function isJQueryMethod(methodName) {
|
|
24
|
+
return JQUERY_METHODS.includes(methodName);
|
|
25
|
+
}
|
|
26
|
+
export function isJQueryCall(node) {
|
|
27
|
+
if (AST_NODE_TYPES.MemberExpression !== node.callee.type || !('name' in node.callee.property)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// Walk to the root object of the call chain
|
|
31
|
+
let obj = node.callee.object ?? null;
|
|
32
|
+
if (null === obj) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
while (AST_NODE_TYPES.MemberExpression === obj.type) {
|
|
36
|
+
obj = obj.object;
|
|
37
|
+
}
|
|
38
|
+
return (AST_NODE_TYPES.CallExpression === obj.type && AST_NODE_TYPES.Identifier === obj.callee.type &&
|
|
39
|
+
('$' === obj.callee.name || 'jQuery' === obj.callee.name));
|
|
40
|
+
}
|
|
41
|
+
export function getJQueryCall(node) {
|
|
42
|
+
// Detect $(...).method(userInput) or jQuery(...).method(...)
|
|
43
|
+
if (AST_NODE_TYPES.MemberExpression !== node.callee.type || !('name' in node.callee.property)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const methodName = node.callee.property.name;
|
|
47
|
+
if (!isJQueryMethod(methodName)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return isJQueryCall(node) ? methodName : null;
|
|
51
|
+
}
|
|
52
|
+
const plugin = {
|
|
53
|
+
defaultOptions: [],
|
|
54
|
+
meta: {
|
|
55
|
+
docs: {
|
|
56
|
+
description: 'Disallow using unsanitized values in jQuery methods that execute HTML',
|
|
57
|
+
},
|
|
58
|
+
fixable: 'code',
|
|
59
|
+
hasSuggestions: true,
|
|
60
|
+
messages: {
|
|
61
|
+
needsEscaping: 'Any HTML used with `{{methodName}}` gets executed. Make sure it\'s properly escaped.',
|
|
62
|
+
// Suggestions
|
|
63
|
+
domPurify: 'Wrap the argument with a `DOMPurify.sanitize()` call.',
|
|
64
|
+
sanitize: 'Wrap the argument with a `sanitize()` call.',
|
|
65
|
+
},
|
|
66
|
+
schema: [],
|
|
67
|
+
type: 'problem',
|
|
68
|
+
},
|
|
69
|
+
create(context) {
|
|
70
|
+
return {
|
|
71
|
+
CallExpression(node) {
|
|
72
|
+
const methodName = getJQueryCall(node);
|
|
73
|
+
if (null !== methodName) {
|
|
74
|
+
const arg = node.arguments[0];
|
|
75
|
+
if (!isSanitized(arg) && !isJQueryElementType(arg, context)) {
|
|
76
|
+
context.report({
|
|
77
|
+
node,
|
|
78
|
+
messageId: 'needsEscaping',
|
|
79
|
+
data: {
|
|
80
|
+
methodName,
|
|
81
|
+
},
|
|
82
|
+
suggest: [
|
|
83
|
+
{
|
|
84
|
+
messageId: 'domPurify',
|
|
85
|
+
fix: (fixer) => {
|
|
86
|
+
const argText = context.sourceCode.getText(arg);
|
|
87
|
+
return fixer.replaceText(arg, `DOMPurify.sanitize( ${argText} )`);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
messageId: 'sanitize',
|
|
92
|
+
fix: (fixer) => {
|
|
93
|
+
const argText = context.sourceCode.getText(arg);
|
|
94
|
+
return fixer.replaceText(arg, `sanitize( ${argText} )`);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
export default plugin;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
import { isJQueryCall } from './jquery-executing.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detects a vulnerable tag stripping pattern: .html(value).text()
|
|
5
|
+
*
|
|
6
|
+
* This pattern can lead to XSS vulnerabilities when HTML is inserted and then text is extracted.
|
|
7
|
+
*
|
|
8
|
+
* @link https://docs.wpvip.com/security/javascript-security-recommendations/#h-stripping-tags
|
|
9
|
+
*/
|
|
10
|
+
function isTextAfterHtml(node) {
|
|
11
|
+
// Check if this is a .text() call
|
|
12
|
+
if (AST_NODE_TYPES.MemberExpression !== node.callee.type || !('name' in node.callee.property)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (node.callee.property.name !== 'text') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const parentCall = node.callee.object;
|
|
19
|
+
if (AST_NODE_TYPES.CallExpression !== parentCall.type) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (AST_NODE_TYPES.MemberExpression !== parentCall.callee.type || !('name' in parentCall.callee.property)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (parentCall.callee.property.name !== 'html') {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return isJQueryCall(parentCall) ? parentCall.callee : null;
|
|
29
|
+
}
|
|
30
|
+
const plugin = {
|
|
31
|
+
defaultOptions: [],
|
|
32
|
+
meta: {
|
|
33
|
+
docs: {
|
|
34
|
+
description: 'Disallow jQuery .html().text() chaining which can lead to XSS through tag stripping',
|
|
35
|
+
},
|
|
36
|
+
fixable: 'code',
|
|
37
|
+
hasSuggestions: true,
|
|
38
|
+
messages: {
|
|
39
|
+
vulnerableTagStripping: 'Using .html().text() can lead to XSS vulnerabilities through tag stripping. Use only .text()',
|
|
40
|
+
useTextOnly: 'Remove .html() and move the argument to .text()',
|
|
41
|
+
},
|
|
42
|
+
schema: [],
|
|
43
|
+
type: 'problem',
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
return {
|
|
47
|
+
CallExpression(node) {
|
|
48
|
+
const htmlProperty = isTextAfterHtml(node);
|
|
49
|
+
if (null === htmlProperty) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const jquerySelector = htmlProperty.object;
|
|
53
|
+
const parentCall = htmlProperty.parent;
|
|
54
|
+
if (AST_NODE_TYPES.CallExpression !== parentCall.type) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const htmlArg = parentCall.arguments[0];
|
|
58
|
+
context.report({
|
|
59
|
+
node,
|
|
60
|
+
messageId: 'vulnerableTagStripping',
|
|
61
|
+
suggest: [
|
|
62
|
+
{
|
|
63
|
+
messageId: 'useTextOnly',
|
|
64
|
+
fix: (fixer) => {
|
|
65
|
+
const selectorText = context.sourceCode.getText(jquerySelector);
|
|
66
|
+
const argText = context.sourceCode.getText(htmlArg);
|
|
67
|
+
return fixer.replaceText(node, `${selectorText}.text( ${argText} )`);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export default plugin;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
import { isLiteralString, isSanitized } from '../utils/shared.js';
|
|
3
|
+
// Window and location properties that need special handling
|
|
4
|
+
const LOCATION_PROPS = new Set(['href', 'src', 'action',
|
|
5
|
+
'protocol', 'host', 'hostname', 'pathname', 'search', 'hash', 'username', 'port', 'name', 'status',
|
|
6
|
+
]);
|
|
7
|
+
const WINDOW_PROPS = new Set(['name', 'status']);
|
|
8
|
+
export function isSafeUrlString(value) {
|
|
9
|
+
return !/^\s*(?:javascript|data|vbscript|about|livescript)\s*:/i.test(decodeURIComponent(value.replace(/[\u0000-\u001F\u007F]+/g, '')));
|
|
10
|
+
}
|
|
11
|
+
function isSafeUrlLiteral(node) {
|
|
12
|
+
return isLiteralString(node) && isSafeUrlString(node.value);
|
|
13
|
+
}
|
|
14
|
+
function isWindowLocationAssignment(node) {
|
|
15
|
+
// window.location.<prop> = ...
|
|
16
|
+
return (AST_NODE_TYPES.MemberExpression === node.left.type &&
|
|
17
|
+
AST_NODE_TYPES.MemberExpression === node.left.object.type &&
|
|
18
|
+
AST_NODE_TYPES.Identifier === node.left.object.property.type &&
|
|
19
|
+
AST_NODE_TYPES.Identifier === node.left.object.object.type &&
|
|
20
|
+
AST_NODE_TYPES.Identifier === node.left.property.type &&
|
|
21
|
+
'window' === node.left.object.object.name &&
|
|
22
|
+
'location' === node.left.object.property.name &&
|
|
23
|
+
LOCATION_PROPS.has(node.left.property.name));
|
|
24
|
+
}
|
|
25
|
+
function isWindowAssignment(node) {
|
|
26
|
+
// window.<prop> = ...
|
|
27
|
+
return (AST_NODE_TYPES.MemberExpression === node.left.type &&
|
|
28
|
+
AST_NODE_TYPES.Identifier === node.left.object.type &&
|
|
29
|
+
AST_NODE_TYPES.Identifier === node.left.property.type &&
|
|
30
|
+
'window' === node.left.object.name &&
|
|
31
|
+
WINDOW_PROPS.has(node.left.property.name));
|
|
32
|
+
}
|
|
33
|
+
function isWindowOrLocation(expression) {
|
|
34
|
+
// Helper to detect a window.* or window.location.*
|
|
35
|
+
if (AST_NODE_TYPES.MemberExpression !== expression.type) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (AST_NODE_TYPES.Identifier === expression.object.type && 'window' === expression.object.name) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (AST_NODE_TYPES.MemberExpression === expression.object.type) {
|
|
42
|
+
const memberObject = expression.object;
|
|
43
|
+
const isObjectWindow = AST_NODE_TYPES.Identifier === memberObject.object.type && 'window' === memberObject.object.name;
|
|
44
|
+
const isPropertyLocation = AST_NODE_TYPES.Identifier === memberObject.property.type && 'location' === memberObject.property.name;
|
|
45
|
+
return isObjectWindow && isPropertyLocation;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const plugin = {
|
|
50
|
+
defaultOptions: [],
|
|
51
|
+
meta: {
|
|
52
|
+
type: 'problem',
|
|
53
|
+
docs: {
|
|
54
|
+
description: 'Require proper escaping for the window and location property access',
|
|
55
|
+
},
|
|
56
|
+
messages: {
|
|
57
|
+
unsafeWindow: 'Assignment to "{{propName}}" must be sanitized.',
|
|
58
|
+
unsafeWindowLocation: 'Assignment to window.location.{{propName}} must be sanitized.',
|
|
59
|
+
unsafeRead: 'Data from JS global {{propName}} may contain user-supplied values and should be sanitized before output to prevent XSS.',
|
|
60
|
+
// Suggestions
|
|
61
|
+
domPurify: 'Wrap the argument with a `DOMPurify.sanitize()` call.',
|
|
62
|
+
sanitize: 'Wrap the argument with a `sanitize()` call.',
|
|
63
|
+
},
|
|
64
|
+
schema: [],
|
|
65
|
+
hasSuggestions: true,
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
return {
|
|
69
|
+
AssignmentExpression(node) {
|
|
70
|
+
const right = node.right;
|
|
71
|
+
if (AST_NODE_TYPES.MemberExpression !== node.left.type || !('name' in node.left.property)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const propName = node.left.property.name;
|
|
75
|
+
// window.location.<prop> = ...
|
|
76
|
+
if (isWindowLocationAssignment(node)) {
|
|
77
|
+
const rhsResolved = right;
|
|
78
|
+
if (!LOCATION_PROPS.has(propName)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (isSafeUrlLiteral(rhsResolved) || isSanitized(rhsResolved)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
context.report({
|
|
85
|
+
node,
|
|
86
|
+
messageId: 'unsafeWindowLocation',
|
|
87
|
+
data: {
|
|
88
|
+
propName,
|
|
89
|
+
},
|
|
90
|
+
suggest: [
|
|
91
|
+
{
|
|
92
|
+
messageId: 'sanitize',
|
|
93
|
+
fix: (fixer) => {
|
|
94
|
+
const argText = context.sourceCode.getText(right);
|
|
95
|
+
return fixer.replaceText(right, `sanitize( ${argText} )`);
|
|
96
|
+
},
|
|
97
|
+
}, {
|
|
98
|
+
messageId: 'domPurify',
|
|
99
|
+
fix: (fixer) => {
|
|
100
|
+
const argText = context.sourceCode.getText(right);
|
|
101
|
+
return fixer.replaceText(right, `DOMPurify.sanitize( ${argText} )`);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// window.<prop> = ...
|
|
109
|
+
if (isWindowAssignment(node)) {
|
|
110
|
+
if (isSanitized(node.right)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
context.report({
|
|
114
|
+
node,
|
|
115
|
+
messageId: 'unsafeWindow',
|
|
116
|
+
data: {
|
|
117
|
+
propName,
|
|
118
|
+
},
|
|
119
|
+
suggest: [
|
|
120
|
+
{
|
|
121
|
+
messageId: 'sanitize',
|
|
122
|
+
fix: (fixer) => {
|
|
123
|
+
const argText = context.sourceCode.getText(right);
|
|
124
|
+
return fixer.replaceText(right, `sanitize( ${argText} )`);
|
|
125
|
+
},
|
|
126
|
+
}, {
|
|
127
|
+
messageId: 'domPurify',
|
|
128
|
+
fix: (fixer) => {
|
|
129
|
+
const argText = context.sourceCode.getText(right);
|
|
130
|
+
return fixer.replaceText(right, `DOMPurify.sanitize( ${argText} )`);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
// Check for reading from the window.location properties
|
|
138
|
+
MemberExpression(node) {
|
|
139
|
+
const parent = node.parent;
|
|
140
|
+
if (AST_NODE_TYPES.AssignmentExpression === parent.type && parent.left === node) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!isWindowOrLocation(node) || !('name' in node.property)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const propName = node.property.name;
|
|
147
|
+
if (!LOCATION_PROPS.has(propName)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (AST_NODE_TYPES.CallExpression === parent.type && isSanitized(parent)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
context.report({
|
|
154
|
+
node,
|
|
155
|
+
messageId: 'unsafeRead',
|
|
156
|
+
data: {
|
|
157
|
+
propName,
|
|
158
|
+
},
|
|
159
|
+
suggest: [
|
|
160
|
+
{
|
|
161
|
+
messageId: 'sanitize',
|
|
162
|
+
fix: (fixer) => {
|
|
163
|
+
const argText = context.sourceCode.getText(node);
|
|
164
|
+
return fixer.replaceText(node, `sanitize( ${argText} )`);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
messageId: 'domPurify',
|
|
169
|
+
fix: (fixer) => {
|
|
170
|
+
const argText = context.sourceCode.getText(node);
|
|
171
|
+
return fixer.replaceText(node, `DOMPurify.sanitize( ${argText} )`);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
export default plugin;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
import {} from 'typescript';
|
|
3
|
+
/**
|
|
4
|
+
* Is the node of type string.
|
|
5
|
+
* - String literals.
|
|
6
|
+
* - constants of type string.
|
|
7
|
+
* - template literals.
|
|
8
|
+
* - intrinsic type string.
|
|
9
|
+
*/
|
|
10
|
+
export function isStringLike(node, context) {
|
|
11
|
+
const type = getType(node, context);
|
|
12
|
+
const literal = type.isStringLiteral();
|
|
13
|
+
const intrinsic = 'intrinsicName' in type && 'string' === type.intrinsicName;
|
|
14
|
+
return (AST_NODE_TYPES.Literal === node.type && 'string' === typeof node.value) || (AST_NODE_TYPES.TemplateLiteral === node.type) || literal || intrinsic;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get the TypeScript type of node.
|
|
18
|
+
*/
|
|
19
|
+
export function getType(expression, context) {
|
|
20
|
+
const { getTypeAtLocation } = ESLintUtils.getParserServices(context);
|
|
21
|
+
const type = getTypeAtLocation(expression);
|
|
22
|
+
return type.getNonNullableType();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Is the type of variable being passed a DOM element?
|
|
26
|
+
*
|
|
27
|
+
* - DOM elements are of the type `HTML{*}Element`.
|
|
28
|
+
* - DOM elements do not require sanitization.
|
|
29
|
+
*
|
|
30
|
+
* @link https://typescript-eslint.io/developers/custom-rules/#typed-rules
|
|
31
|
+
*/
|
|
32
|
+
export function isDomElementType(expression, context) {
|
|
33
|
+
const type = getType(expression, context);
|
|
34
|
+
const name = type.getSymbol()?.escapedName ?? '';
|
|
35
|
+
// Match any type that ends with "Element", e.g., HTMLElement, HTMLDivElement, Element, etc.
|
|
36
|
+
if ('Element' === name) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return name.startsWith('HTML') && name.endsWith('Element');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a node is a call to a known sanitization function.
|
|
43
|
+
* - Currently recognizes `sanitize(...)` and `DOMPurify.sanitize(...)`.
|
|
44
|
+
*/
|
|
45
|
+
export function isSanitized(node) {
|
|
46
|
+
if (AST_NODE_TYPES.CallExpression !== node.type) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (AST_NODE_TYPES.Identifier === node.callee.type && 'sanitize' === node.callee.name) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (AST_NODE_TYPES.MemberExpression === node.callee.type && AST_NODE_TYPES.Identifier === node.callee.object.type) {
|
|
53
|
+
return 'dompurify' === node.callee.object.name.toLowerCase() &&
|
|
54
|
+
AST_NODE_TYPES.Identifier === node.callee.property.type && 'sanitize' === node.callee.property.name;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a node is a literal string
|
|
60
|
+
*/
|
|
61
|
+
export function isLiteralString(node) {
|
|
62
|
+
return AST_NODE_TYPES.Literal === node.type && 'string' === typeof node.value;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if a node is a literal string that is safe to use in an HTML context.
|
|
66
|
+
* - Must be a literal string.
|
|
67
|
+
* - Must not contain `<script`.
|
|
68
|
+
* - Must not start with a dangerous protocol (javascript:, data:, vbscript:, about:, livescript:).
|
|
69
|
+
*/
|
|
70
|
+
export function isSafeLiteralString(node) {
|
|
71
|
+
if (!isLiteralString(node)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (node.value.includes('<script')) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return !/^\s*(?:javascript|data|vbscript|about|livescript)\s*:/i.test(decodeURIComponent(node.value.replace(/[\u0000-\u001F\u007F]+/g, '')));
|
|
78
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FlatConfig } from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
/**
|
|
3
|
+
* Get a config from our /index.js merged with any
|
|
4
|
+
* matching configuration from the project directory.
|
|
5
|
+
*
|
|
6
|
+
* For instance, if we have a file named config/eslint.config.js in our project
|
|
7
|
+
* we will merge the contents with our config/eslint.config.js in favor of whatever
|
|
8
|
+
* is specified with the project's file.
|
|
9
|
+
*
|
|
10
|
+
* If the `module.exports` are a function, the existing configuration will be passed
|
|
11
|
+
* as the only argument. Otherwise, standard `module.exports` are also supported.
|
|
12
|
+
*
|
|
13
|
+
* @see @lipemat/js-boilerplate/helpers/config
|
|
14
|
+
*
|
|
15
|
+
* @example ```ts
|
|
16
|
+
* // function
|
|
17
|
+
* module.exports = function( config: { configs: Linter.Config[] } ) {
|
|
18
|
+
* config.configs[0].push({extra: 'Extra'});
|
|
19
|
+
* return config
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
export declare function getConfig(configs: FlatConfig.Config[]): FlatConfig.Config[];
|
package/types/index.d.ts
ADDED