eslint-plugin-chrome-extensions 2.6.0 → 2.8.0
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/README.md +648 -33
- package/package.json +11 -9
- package/src/data/apis.js +74 -88
- package/src/data/manifest.js +3 -1
- package/src/data/regex.js +3 -1
- package/src/index.d.ts +42 -0
- package/src/index.js +0 -4
- package/src/rules/css-spacing.js +375 -0
- package/src/rules/manifest-key-order.js +1 -2
- package/src/rules/manifest-spacing.js +1 -2
- package/src/rules/no-content-style-leak.js +12 -11
- package/src/rules/no-deprecated-apis.js +2 -2
- package/src/rules/no-duplicate-manifest-keys.js +0 -1
- package/src/rules/no-global-keyframes.js +84 -0
- package/src/rules/no-host-page-selectors.js +105 -0
- package/src/rules/no-inline-event-handlers.js +116 -0
- package/src/rules/no-inline-scripts.js +65 -0
- package/src/rules/no-insecure-fetch.js +7 -6
- package/src/rules/no-insecure-messaging.js +4 -4
- package/src/rules/no-invalid-locales.js +3 -5
- package/src/rules/no-missing-default-locale.js +1 -1
- package/src/rules/no-missing-permissions.js +2 -3
- package/src/rules/no-missing-web-accessible-resources.js +2 -2
- package/src/rules/no-remote-code.js +77 -28
- package/src/rules/no-remote-objects.js +55 -0
- package/src/rules/no-remote-urls.js +102 -0
- package/src/rules/no-scrollbar-styling.js +71 -0
- package/src/rules/no-unrestricted-host-permissions.js +1 -1
- package/src/rules/no-unused-i18n-keys.js +0 -1
- package/src/rules/no-unused-permissions.js +5 -4
- package/src/rules/require-license-file.js +1 -2
- package/src/rules/require-mv3.js +3 -2
- package/src/rules/require-short-name-when-useful.js +0 -1
- package/src/rules/valid-filenames.js +3 -3
- package/src/rules/valid-license-file.js +2 -4
- package/src/rules/valid-manifest-icons.js +2 -2
- package/src/rules/valid-mv3-schema.js +5 -5
- package/src/rules/valid-url-fields.js +2 -2
- package/src/rules/valid-version-field.js +2 -2
- package/src/utils/api.js +20 -0
- package/src/utils/array.js +1 -0
- package/src/utils/extension.js +1 -3
- package/src/utils/html.js +29 -0
- package/src/utils/i18n.js +2 -2
- package/src/utils/init.js +21 -9
- package/src/utils/license.js +1 -1
- package/src/utils/node.js +3 -1
- package/src/utils/pkg.js +19 -2
- package/src/utils/rules.js +1 -1
- package/src/data/deprecations.js +0 -8
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* oxlint-disable security/detect-object-injection */
|
|
2
|
+
|
|
3
|
+
import array from '../utils/array.js'
|
|
4
|
+
import html from '../utils/html.js'
|
|
5
|
+
|
|
6
|
+
const re = {
|
|
7
|
+
attrNameEnd: /[\s=>]/,
|
|
8
|
+
block: {
|
|
9
|
+
script: /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)/gi,
|
|
10
|
+
style: /(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi
|
|
11
|
+
},
|
|
12
|
+
htmlComment: /<!--[\s\S]*?-->/g,
|
|
13
|
+
inlineHandler: /^on[a-z]+$/i,
|
|
14
|
+
quote: /["']/,
|
|
15
|
+
tagOpen: /<[a-z][\w-]*/gi,
|
|
16
|
+
whitespace: /\s/
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isQuote(char) { return re.quote.test(char) }
|
|
20
|
+
function maskInner(_, open, inner, close) { return open + spaces(inner.length) + close }
|
|
21
|
+
function spaces(n) { return ' '.repeat(n) }
|
|
22
|
+
|
|
23
|
+
function parseAttrs(attrPart) {
|
|
24
|
+
const attrs = [] ; let i = 0
|
|
25
|
+
while (i < attrPart.length) {
|
|
26
|
+
while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
|
|
27
|
+
if (i >= attrPart.length) break
|
|
28
|
+
const nameStart = i
|
|
29
|
+
while (i < attrPart.length && !re.attrNameEnd.test(attrPart[i])) i++
|
|
30
|
+
const name = attrPart.slice(nameStart, i)
|
|
31
|
+
if (!name) { i++ ; continue }
|
|
32
|
+
while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
|
|
33
|
+
if (attrPart[i] == '=') {
|
|
34
|
+
i++
|
|
35
|
+
while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
|
|
36
|
+
if (isQuote(attrPart[i])) {
|
|
37
|
+
const q = attrPart[i]
|
|
38
|
+
i++
|
|
39
|
+
while (i < attrPart.length && attrPart[i] !== q) i++
|
|
40
|
+
i++
|
|
41
|
+
} else
|
|
42
|
+
while (i < attrPart.length && !re.whitespace.test(attrPart[i]) && attrPart[i] != '>') i++
|
|
43
|
+
}
|
|
44
|
+
attrs.push({ name, offset: nameStart })
|
|
45
|
+
}
|
|
46
|
+
return attrs
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
meta: {
|
|
51
|
+
type: 'problem',
|
|
52
|
+
docs: {
|
|
53
|
+
description: 'Disallow inline event handlers (e.g. `onclick`, `onload`) in HTML '
|
|
54
|
+
+ '(blocked by MV3\'s CSP at runtime)',
|
|
55
|
+
category: 'Possible Errors',
|
|
56
|
+
html: true
|
|
57
|
+
},
|
|
58
|
+
defaultOptions: [{ allowedAttributes: [] }],
|
|
59
|
+
schema: [{
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
allowedAttributes: {
|
|
63
|
+
oneOf: [
|
|
64
|
+
{ type: 'string', description: 'A single attribute name' },
|
|
65
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of attribute names' }
|
|
66
|
+
],
|
|
67
|
+
description: 'Inline event handler attributes to permit'
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
additionalProperties: false
|
|
71
|
+
}],
|
|
72
|
+
messages: {
|
|
73
|
+
inlineHandler: 'Inline event handler `{{attr}}` is blocked by MV3\'s `script-src \'self\'`. '
|
|
74
|
+
+ 'Move the handler to JS with `addEventListener()`.'
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
create(context) {
|
|
79
|
+
const opts = context.options[0] || {},
|
|
80
|
+
allowedAttributes = new Set(array.to(opts.allowedAttributes ?? []).map(a => a.toLowerCase())),
|
|
81
|
+
text = context.sourceCode.text
|
|
82
|
+
let scanned = false
|
|
83
|
+
function maskContent() {
|
|
84
|
+
return text
|
|
85
|
+
.replace(re.htmlComment, match => spaces(match.length))
|
|
86
|
+
.replace(re.block.script, maskInner)
|
|
87
|
+
.replace(re.block.style, maskInner)
|
|
88
|
+
}
|
|
89
|
+
function scan() {
|
|
90
|
+
if (scanned) return
|
|
91
|
+
scanned = true
|
|
92
|
+
if (!html.isHTML(text)) return
|
|
93
|
+
const masked = maskContent()
|
|
94
|
+
for (const tagMatch of masked.matchAll(re.tagOpen)) {
|
|
95
|
+
const tagEnd = tagMatch.index + tagMatch[0].length
|
|
96
|
+
let i = tagEnd, quote = null
|
|
97
|
+
while (i < masked.length) {
|
|
98
|
+
const char = masked[i]
|
|
99
|
+
if (quote) { if (char === quote) quote = null }
|
|
100
|
+
else if (isQuote(char)) quote = char
|
|
101
|
+
else if (char == '>') break
|
|
102
|
+
i++
|
|
103
|
+
}
|
|
104
|
+
const attrPart = masked.slice(tagEnd, i)
|
|
105
|
+
for (const attr of parseAttrs(attrPart)) {
|
|
106
|
+
if (!re.inlineHandler.test(attr.name)) continue
|
|
107
|
+
const attrLower = attr.name.toLowerCase()
|
|
108
|
+
if (allowedAttributes.has(attrLower)) continue
|
|
109
|
+
context.report({ loc: html.offsetToLoc(text, tagEnd + attr.offset),
|
|
110
|
+
messageId: 'inlineHandler', data: { attr: attrLower }})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import html from '../utils/html.js'
|
|
3
|
+
|
|
4
|
+
const re = {
|
|
5
|
+
nonExecutable: /^(?:application\/(?:ld\+)?json|importmap|speculationrules|text\/(?:x-)?template)$/i,
|
|
6
|
+
script: /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: {
|
|
13
|
+
description: 'Disallow inline `<script>` bodies and remote `<script src>` in extension HTML',
|
|
14
|
+
category: 'Possible Errors',
|
|
15
|
+
html: true
|
|
16
|
+
},
|
|
17
|
+
defaultOptions: [{ allowedSrcPrefixes: ['chrome-extension://'] }],
|
|
18
|
+
schema: [{
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
allowedSrcPrefixes: {
|
|
22
|
+
oneOf: [
|
|
23
|
+
{ type: 'string', description: 'A single allowed src prefix' },
|
|
24
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of allowed src prefixes' }
|
|
25
|
+
],
|
|
26
|
+
description: 'Additional src prefixes to permit'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
}],
|
|
31
|
+
messages: {
|
|
32
|
+
inlineBody: 'Inline `<script>` bodies are blocked by MV3\'s `script-src \'self\'`. Move the code to an external file.',
|
|
33
|
+
remoteSrc: 'Remote `<script src="{{src}}">` is blocked by MV3\'s `script-src \'self\'`. Bundle the script or use `chrome.runtime.getURL()`.'
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
create(context) {
|
|
38
|
+
const opts = context.options[0] || {},
|
|
39
|
+
allowedPrefixes = array.to(opts.allowedSrcPrefixes ?? ['chrome-extension://']),
|
|
40
|
+
text = context.sourceCode.text
|
|
41
|
+
let scanned = false
|
|
42
|
+
function scan() {
|
|
43
|
+
if (scanned) return
|
|
44
|
+
scanned = true
|
|
45
|
+
if (!html.isHTML(text)) return
|
|
46
|
+
for (const match of text.matchAll(re.script)) {
|
|
47
|
+
const [, attrs, body] = match,
|
|
48
|
+
type = html.pickAttrVal(attrs.match(html.re.attr.type))
|
|
49
|
+
if (type && re.nonExecutable.test(type.trim())) continue
|
|
50
|
+
const src = html.pickAttrVal(attrs.match(html.re.attr.src))
|
|
51
|
+
if (src !== null) {
|
|
52
|
+
const trimmed = src.trim()
|
|
53
|
+
if (trimmed && html.isRemote(trimmed, allowedPrefixes))
|
|
54
|
+
context.report({ loc: html.offsetToLoc(text, match.index),
|
|
55
|
+
messageId: 'remoteSrc', data: { src: trimmed }})
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (body && body.trim().length > 0)
|
|
59
|
+
context.report({ loc: html.offsetToLoc(text, match.index),
|
|
60
|
+
messageId: 'inlineBody' })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const re = { insecureURL: /^http:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])/i }
|
|
2
2
|
|
|
3
|
+
function urlFromLiteral(node) {
|
|
4
|
+
return ( node?.type == 'Literal'
|
|
5
|
+
&& typeof node.value == 'string'
|
|
6
|
+
&& re.insecureURL.test(node.value)
|
|
7
|
+
) ? node.value : null
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export default {
|
|
4
11
|
meta: {
|
|
5
12
|
type: 'problem',
|
|
@@ -18,12 +25,6 @@ export default {
|
|
|
18
25
|
|
|
19
26
|
create(context) {
|
|
20
27
|
const xhrVars = new Set()
|
|
21
|
-
function urlFromLiteral(node) {
|
|
22
|
-
return ( node?.type == 'Literal'
|
|
23
|
-
&& typeof node.value == 'string'
|
|
24
|
-
&& re.insecureURL.test(node.value)
|
|
25
|
-
) ? node.value : null
|
|
26
|
-
}
|
|
27
28
|
return {
|
|
28
29
|
VariableDeclarator(node) {
|
|
29
30
|
if (node.init?.type == 'NewExpression'
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import api from '../utils/api.js'
|
|
1
2
|
import nodeUtils from '../utils/node.js'
|
|
2
|
-
import apis from '../data/apis.js'
|
|
3
3
|
|
|
4
|
-
const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ }
|
|
4
|
+
const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ },
|
|
5
|
+
isMessageTarget = obj => obj?.type == 'Identifier' && ['self', 'window'].includes(obj.name)
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
meta: {
|
|
@@ -40,13 +41,12 @@ export default {
|
|
|
40
41
|
if (re.postmsgValidation.test(body)) return
|
|
41
42
|
context.report({ node, messageId: 'unvalidatedPostMessage', data: { target }})
|
|
42
43
|
}
|
|
43
|
-
function isMessageTarget(obj) { return obj?.type == 'Identifier' && ['self', 'window'].includes(obj.name) }
|
|
44
44
|
return {
|
|
45
45
|
CallExpression(node) {
|
|
46
46
|
if (node.callee.type != 'MemberExpression') return
|
|
47
47
|
const path = nodeUtils.getMemberPath(node.callee) ; if (!path) return
|
|
48
48
|
const listenerRoot = path.endsWith('.addListener') ? path.slice(0, -'.addListener'.length) : null
|
|
49
|
-
if (listenerRoot &&
|
|
49
|
+
if (listenerRoot && api.isExternalMsgListener(listenerRoot)) {
|
|
50
50
|
const handler = node.arguments[0] ; if (!handler) return
|
|
51
51
|
const body = context.sourceCode.getText(handler)
|
|
52
52
|
if (!re.senderValidation.test(body))
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
|
|
2
|
-
|
|
3
1
|
import extension from '../utils/extension.js'
|
|
4
2
|
import fs from 'node:fs'
|
|
5
3
|
import i18nUtils from '../utils/i18n.js'
|
|
@@ -51,11 +49,11 @@ export default {
|
|
|
51
49
|
root = extension.findRoot(context)
|
|
52
50
|
if (root) {
|
|
53
51
|
const localesDir = path.join(root, '_locales')
|
|
54
|
-
try {
|
|
52
|
+
try { // oxlint-disable-next-line security/detect-non-literal-fs-filename
|
|
55
53
|
for (const entry of fs.readdirSync(localesDir, { withFileTypes: true }))
|
|
56
54
|
if (entry.isDirectory() && isInvalid(entry.name)) invalidFolders.push(entry.name)
|
|
57
|
-
invalidFolders.sort()
|
|
58
|
-
} catch {}
|
|
55
|
+
invalidFolders.sort((a, b) => a < b ? -1 : a > b ? 1 : 0)
|
|
56
|
+
} catch {}
|
|
59
57
|
}
|
|
60
58
|
const loc = { line: 1, column: 1 }
|
|
61
59
|
if (!isManifest) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import apiUtils from '../utils/api.js'
|
|
1
2
|
import extension from '../utils/extension.js'
|
|
2
3
|
import nodeUtils from '../utils/node.js'
|
|
3
4
|
|
|
4
|
-
import apis from '../data/apis.js'
|
|
5
|
-
|
|
6
5
|
function queriesActiveTab(node) {
|
|
7
6
|
const arg = node.arguments[0] ; if (arg?.type != 'ObjectExpression') return false
|
|
8
7
|
return arg.properties.some(prop => {
|
|
@@ -34,7 +33,7 @@ export default {
|
|
|
34
33
|
CallExpression(node) {
|
|
35
34
|
if (node.callee.type != 'MemberExpression') return
|
|
36
35
|
const api = nodeUtils.getMemberPath(node.callee) ; if (!api) return
|
|
37
|
-
const permission =
|
|
36
|
+
const permission = apiUtils.findPermission(api) ; if (!permission || declared.has(permission)) return
|
|
38
37
|
const isActiveTabQuery = permission == 'tabs' && queriesActiveTab(node)
|
|
39
38
|
if (isActiveTabQuery && declared.has('activeTab')) return
|
|
40
39
|
const messageId = `missing${ isActiveTabQuery ? 'ActiveTab' : 'Permission' }`
|
|
@@ -16,8 +16,8 @@ function collectWARpatterns(manifest) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function patternToRegex(pattern) {
|
|
19
|
-
const escaped = pattern.
|
|
20
|
-
return new RegExp(`^${escaped}$`) //
|
|
19
|
+
const escaped = pattern.replaceAll(re.regexSpecial, '\\$&').replaceAll('*', '.*')
|
|
20
|
+
return new RegExp(`^${escaped}$`) // oxlint-disable-line security/detect-non-literal-regexp
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export default {
|
|
@@ -3,6 +3,38 @@ import nodeUtils from '../utils/node.js'
|
|
|
3
3
|
import manifest from '../data/manifest.js'
|
|
4
4
|
import regex from '../data/regex.js'
|
|
5
5
|
|
|
6
|
+
const re = { inlineHandler: /^on[a-z]+$/i, inlineHandlerInHTML: /\son[a-z]+\s*=/i }
|
|
7
|
+
|
|
8
|
+
function isCreateScript(node) {
|
|
9
|
+
return node?.type == 'CallExpression'
|
|
10
|
+
&& node.callee?.property?.name == 'createElement'
|
|
11
|
+
&& node.arguments[0]?.value == 'script'
|
|
12
|
+
}
|
|
13
|
+
function isInlineHandlerInString(node) {
|
|
14
|
+
return node?.type == 'Literal'
|
|
15
|
+
&& typeof node.value == 'string'
|
|
16
|
+
&& re.inlineHandlerInHTML.test(node.value)
|
|
17
|
+
}
|
|
18
|
+
function isLiteralURL(node) {
|
|
19
|
+
return node?.type == 'Literal'
|
|
20
|
+
&& typeof node.value == 'string'
|
|
21
|
+
&& regex.url.test(node.value)
|
|
22
|
+
}
|
|
23
|
+
function isObjectAssign(node) {
|
|
24
|
+
return node?.type == 'CallExpression'
|
|
25
|
+
&& node.callee?.type == 'MemberExpression'
|
|
26
|
+
&& node.callee.object?.name == 'Object'
|
|
27
|
+
&& node.callee.property?.name == 'assign'
|
|
28
|
+
}
|
|
29
|
+
function isScriptSrcProperty(node) {
|
|
30
|
+
const obj = node.parent
|
|
31
|
+
if (obj?.type != 'ObjectExpression') return false
|
|
32
|
+
const parent = obj.parent
|
|
33
|
+
if (isCreateScript(parent)) return true
|
|
34
|
+
if (isObjectAssign(parent) && isCreateScript(parent.arguments[0])) return true
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
6
38
|
export default {
|
|
7
39
|
meta: {
|
|
8
40
|
type: 'problem',
|
|
@@ -13,8 +45,7 @@ export default {
|
|
|
13
45
|
security: true
|
|
14
46
|
},
|
|
15
47
|
defaultOptions: [{
|
|
16
|
-
patterns:
|
|
17
|
-
'dynamic-import', 'remote-script-src', 'execute-script-url'],
|
|
48
|
+
patterns: manifest.patterns.remoteCode,
|
|
18
49
|
checkExecuteScriptClosures: true
|
|
19
50
|
}],
|
|
20
51
|
schema: [{
|
|
@@ -39,31 +70,17 @@ export default {
|
|
|
39
70
|
dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP. Use a relative path instead.',
|
|
40
71
|
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP. Use `chrome.runtime.getURL()` for bundled scripts instead.',
|
|
41
72
|
executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
|
|
42
|
-
executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.'
|
|
73
|
+
executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.',
|
|
74
|
+
inlineEventHandler: 'Inline event handler `{{attr}}` is blocked by MV3\'s `script-src \'self\'`. Use `addEventListener()` instead.',
|
|
75
|
+
inlineScriptBody: 'Inline `<script>` body injection is blocked by MV3\'s `script-src \'self\'`. Move the code to an external file.'
|
|
43
76
|
}
|
|
44
77
|
},
|
|
45
78
|
|
|
46
79
|
create(context) {
|
|
47
80
|
const opts = context.options[0] || {},
|
|
48
|
-
patterns = new Set(opts.patterns || manifest.
|
|
81
|
+
patterns = new Set(opts.patterns || manifest.patterns.remoteCode),
|
|
49
82
|
checkClosures = opts.checkExecuteScriptClosures !== false,
|
|
50
83
|
scriptElems = new Set()
|
|
51
|
-
function isCreateScript(node) {
|
|
52
|
-
return node?.type == 'CallExpression'
|
|
53
|
-
&& node.callee?.property?.name == 'createElement'
|
|
54
|
-
&& node.arguments[0]?.value == 'script'
|
|
55
|
-
}
|
|
56
|
-
function isLiteralURL(node) {
|
|
57
|
-
return node?.type == 'Literal'
|
|
58
|
-
&& typeof node.value == 'string'
|
|
59
|
-
&& regex.url.test(node.value)
|
|
60
|
-
}
|
|
61
|
-
function isObjectAssign(node) {
|
|
62
|
-
return node?.type == 'CallExpression'
|
|
63
|
-
&& node.callee?.type == 'MemberExpression'
|
|
64
|
-
&& node.callee.object?.name == 'Object'
|
|
65
|
-
&& node.callee.property?.name == 'assign'
|
|
66
|
-
}
|
|
67
84
|
function isScriptSrcAssignment(left, right) {
|
|
68
85
|
if (left.type != 'MemberExpression') return false
|
|
69
86
|
if (left.property?.name != 'src') return false
|
|
@@ -72,12 +89,10 @@ export default {
|
|
|
72
89
|
if (left.object.type == 'Identifier' && scriptElems.has(left.object.name)) return true
|
|
73
90
|
return false
|
|
74
91
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
if (isCreateScript(parent)) return true
|
|
80
|
-
if (isObjectAssign(parent) && isCreateScript(parent.arguments[0])) return true
|
|
92
|
+
function isScriptElement(node) {
|
|
93
|
+
if (!node) return false
|
|
94
|
+
if (isCreateScript(node)) return true
|
|
95
|
+
if (node.type == 'Identifier' && scriptElems.has(node.name)) return true
|
|
81
96
|
return false
|
|
82
97
|
}
|
|
83
98
|
return {
|
|
@@ -112,12 +127,31 @@ export default {
|
|
|
112
127
|
if (optsNode?.type != 'ObjectExpression') return
|
|
113
128
|
const urlProp = optsNode.properties.find(prop => prop.key?.name == 'url'),
|
|
114
129
|
funcProp = optsNode.properties.find(prop => prop.key?.name == 'func'),
|
|
115
|
-
|
|
130
|
+
hasArgs = optsNode.properties.some(prop => prop.key?.name == 'args')
|
|
116
131
|
if (patterns.has('execute-script-url') && urlProp)
|
|
117
132
|
context.report({ node: urlProp, messageId: 'executeScriptURL' })
|
|
118
|
-
if (checkClosures && funcProp && !
|
|
133
|
+
if (checkClosures && funcProp && !hasArgs)
|
|
119
134
|
context.report({ node: funcProp, messageId: 'executeScriptClosure' })
|
|
120
135
|
}
|
|
136
|
+
if (patterns.has('inline-event-handler')
|
|
137
|
+
&& node.callee.property?.name == 'setAttribute'
|
|
138
|
+
&& node.arguments[0]?.type == 'Literal'
|
|
139
|
+
&& typeof node.arguments[0].value == 'string'
|
|
140
|
+
&& re.inlineHandler.test(node.arguments[0].value)
|
|
141
|
+
) context.report({ node, messageId: 'inlineEventHandler',
|
|
142
|
+
data: { attr: node.arguments[0].value.toLowerCase() }})
|
|
143
|
+
if (patterns.has('inline-event-handler')
|
|
144
|
+
&& node.callee.property?.name == 'insertAdjacentHTML'
|
|
145
|
+
&& isInlineHandlerInString(node.arguments[1])
|
|
146
|
+
) context.report({ node: node.arguments[1], messageId: 'inlineEventHandler',
|
|
147
|
+
data: { attr: 'on*' }})
|
|
148
|
+
if (patterns.has('inline-script-body')
|
|
149
|
+
&& ['append', 'prepend'].includes(node.callee.property?.name)
|
|
150
|
+
&& isScriptElement(node.callee.object)
|
|
151
|
+
&& node.arguments[0]?.type == 'Literal'
|
|
152
|
+
&& typeof node.arguments[0].value == 'string'
|
|
153
|
+
&& node.arguments[0].value.trim().length > 0
|
|
154
|
+
) context.report({ node: node.arguments[0], messageId: 'inlineScriptBody' })
|
|
121
155
|
}
|
|
122
156
|
},
|
|
123
157
|
NewExpression(node) {
|
|
@@ -125,6 +159,21 @@ export default {
|
|
|
125
159
|
context.report({ node, messageId: 'newFunction' })
|
|
126
160
|
},
|
|
127
161
|
AssignmentExpression(node) {
|
|
162
|
+
if (patterns.has('inline-event-handler')
|
|
163
|
+
&& node.left.type == 'MemberExpression'
|
|
164
|
+
&& ['innerHTML', 'outerHTML'].includes(node.left.property?.name)
|
|
165
|
+
&& !isScriptElement(node.left.object)
|
|
166
|
+
&& isInlineHandlerInString(node.right)
|
|
167
|
+
) context.report({ node: node.right, messageId: 'inlineEventHandler',
|
|
168
|
+
data: { attr: 'on*' }})
|
|
169
|
+
if (patterns.has('inline-script-body')
|
|
170
|
+
&& node.left.type == 'MemberExpression'
|
|
171
|
+
&& ['textContent', 'innerText', 'innerHTML', 'text'].includes(node.left.property?.name)
|
|
172
|
+
&& isScriptElement(node.left.object)
|
|
173
|
+
&& node.right?.type == 'Literal'
|
|
174
|
+
&& typeof node.right.value == 'string'
|
|
175
|
+
&& node.right.value.trim().length > 0
|
|
176
|
+
) context.report({ node, messageId: 'inlineScriptBody' })
|
|
128
177
|
if (!patterns.has('remote-script-src')) return
|
|
129
178
|
if (isScriptSrcAssignment(node.left, node.right))
|
|
130
179
|
context.report({ node: node.right, messageId: 'remoteScriptSrc' })
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import html from '../utils/html.js'
|
|
3
|
+
|
|
4
|
+
const re = { objectTag: /<(?:object|embed)\b([^>]*)>/gi }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Disallow remote `<object data>` and `<embed src>` in extension HTML',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
html: true
|
|
13
|
+
},
|
|
14
|
+
defaultOptions: [{ allowedPrefixes: ['chrome-extension://'] }],
|
|
15
|
+
schema: [{
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
allowedPrefixes: {
|
|
19
|
+
oneOf: [
|
|
20
|
+
{ type: 'string', description: 'A single allowed prefix' },
|
|
21
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of allowed prefixes' }
|
|
22
|
+
],
|
|
23
|
+
description: 'Additional prefixes to permit'
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false
|
|
27
|
+
}],
|
|
28
|
+
messages: {
|
|
29
|
+
remoteObject: 'Remote `<{{tag}} {{attr}}="{{src}}">` is blocked by MV3\'s `object-src \'self\'`. '
|
|
30
|
+
+ 'Bundle the resource or use `chrome.runtime.getURL()`.'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
create(context) {
|
|
35
|
+
const opts = context.options[0] || {},
|
|
36
|
+
allowedPrefixes = array.to(opts.allowedPrefixes ?? ['chrome-extension://']),
|
|
37
|
+
text = context.sourceCode.text
|
|
38
|
+
let scanned = false
|
|
39
|
+
function scan() {
|
|
40
|
+
if (scanned) return
|
|
41
|
+
scanned = true
|
|
42
|
+
if (!html.isHTML(text)) return
|
|
43
|
+
for (const match of text.matchAll(re.objectTag)) {
|
|
44
|
+
const [full, attrs] = match,
|
|
45
|
+
tag = full.trimStart().toLowerCase().startsWith('<object') ? 'object' : 'embed',
|
|
46
|
+
attr = tag == 'object' ? 'data' : 'src',
|
|
47
|
+
val = html.pickAttrVal(attrs.match(tag == 'object' ? html.re.attr.data : html.re.attr.src))
|
|
48
|
+
if (val !== null && html.isRemote(val.trim(), allowedPrefixes))
|
|
49
|
+
context.report({ loc: html.offsetToLoc(text, match.index), messageId: 'remoteObject',
|
|
50
|
+
data: { tag, attr, src: val.trim() }})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import regex from '../data/regex.js'
|
|
3
|
+
|
|
4
|
+
function getPreludeURLnode(prelude) {
|
|
5
|
+
if (!prelude) return null
|
|
6
|
+
if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(prelude.type))
|
|
7
|
+
return prelude
|
|
8
|
+
for (const child of prelude.children ?? [])
|
|
9
|
+
if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(child.type))
|
|
10
|
+
return child
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
function getURLstr(node) {
|
|
14
|
+
if (!node) return null
|
|
15
|
+
if (typeof node == 'string') return node
|
|
16
|
+
if (typeof node.url == 'string') return node.url
|
|
17
|
+
if (typeof node.value == 'string') return node.value
|
|
18
|
+
if (node.value && typeof node.value == 'object' && typeof node.value.value == 'string')
|
|
19
|
+
return node.value.value
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Disallow remote resources (`@import`, `url()`, `@namespace`) in extension CSS',
|
|
28
|
+
category: 'Possible Errors',
|
|
29
|
+
css: true
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [{
|
|
32
|
+
allowedSchemes: ['blob', 'chrome-extension', 'data'],
|
|
33
|
+
patterns: ['at-import', 'url']
|
|
34
|
+
}],
|
|
35
|
+
schema: [{
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
allowedSchemes: {
|
|
39
|
+
oneOf: [
|
|
40
|
+
{ type: 'string', description: 'A single URL scheme' },
|
|
41
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of URL schemes' }
|
|
42
|
+
],
|
|
43
|
+
description: 'URL schemes to allow (e.g. `chrome-extension`, `data`, `blob`)'
|
|
44
|
+
},
|
|
45
|
+
patterns: {
|
|
46
|
+
oneOf: [
|
|
47
|
+
{ enum: ['at-import', 'url', 'namespace'], description: 'A single detection path' },
|
|
48
|
+
{ type: 'array', items: { enum: ['at-import', 'url', 'namespace'] }, description: 'List of detection paths' }
|
|
49
|
+
],
|
|
50
|
+
description: 'Detection paths to enable'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
additionalProperties: false
|
|
54
|
+
}],
|
|
55
|
+
messages: {
|
|
56
|
+
remoteImport: 'Remote `@import` is disallowed. Bundle the stylesheet or use `chrome.runtime.getURL()`.',
|
|
57
|
+
remoteURL: 'Remote `url()` is disallowed. Bundle the asset or use `chrome.runtime.getURL()`.',
|
|
58
|
+
remoteNamespace: 'Remote `@namespace` is disallowed. Reference a bundled stylesheet.'
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
create(context) {
|
|
63
|
+
const opts = context.options[0] || {},
|
|
64
|
+
allowedSchemes = new Set(array.to(opts.allowedSchemes ?? ['blob', 'chrome-extension', 'data'])),
|
|
65
|
+
patterns = new Set(array.to(opts.patterns ?? ['at-import', 'url'])),
|
|
66
|
+
handledURLs = new WeakSet()
|
|
67
|
+
function handleAtrule(node) {
|
|
68
|
+
if (!node.prelude) return
|
|
69
|
+
if (node.name == 'import' && patterns.has('at-import')) {
|
|
70
|
+
const urlNode = getPreludeURLnode(node.prelude)
|
|
71
|
+
if (urlNode && isRemote(getURLstr(urlNode))) {
|
|
72
|
+
handledURLs.add(urlNode)
|
|
73
|
+
context.report({ node, messageId: 'remoteImport' })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (node.name == 'namespace' && patterns.has('namespace')) {
|
|
77
|
+
const urlNode = getPreludeURLnode(node.prelude)
|
|
78
|
+
if (urlNode && isRemote(getURLstr(urlNode))) {
|
|
79
|
+
handledURLs.add(urlNode)
|
|
80
|
+
context.report({ node, messageId: 'remoteNamespace' })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function handleURL(node) {
|
|
85
|
+
if (!patterns.has('url')) return
|
|
86
|
+
if (handledURLs.has(node)) return
|
|
87
|
+
if (node.parent?.type == 'AtrulePrelude' || node.parent?.type == 'CSSAtRulePrelude') return
|
|
88
|
+
if (isRemote(getURLstr(node)))
|
|
89
|
+
context.report({ node, messageId: 'remoteURL' })
|
|
90
|
+
}
|
|
91
|
+
function isRemote(str) {
|
|
92
|
+
if (typeof str != 'string') return false
|
|
93
|
+
const match = regex.scheme.exec(str)
|
|
94
|
+
if (!match) return false
|
|
95
|
+
if (match[0] == '//') return true
|
|
96
|
+
const scheme = (match[1] || '').toLowerCase()
|
|
97
|
+
if (['http', 'https', 'ftp'].includes(scheme)) return true
|
|
98
|
+
return !allowedSchemes.has(scheme)
|
|
99
|
+
}
|
|
100
|
+
return { Atrule: handleAtrule, CSSAtRule: handleAtrule, Url: handleURL, CSSUrl: handleURL }
|
|
101
|
+
}
|
|
102
|
+
}
|