eslint-plugin-chrome-extensions 1.0.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 +767 -0
- package/docs/LICENSE.md +9 -0
- package/docs/SECURITY.md +3 -0
- package/package.json +85 -0
- package/src/data/apis.js +65 -0
- package/src/data/manifest.js +13 -0
- package/src/data/permissions.js +31 -0
- package/src/index.js +58 -0
- package/src/rules/no-content-style-leak.js +65 -0
- package/src/rules/no-deprecated-apis.js +60 -0
- package/src/rules/no-insecure-messaging.js +42 -0
- package/src/rules/no-invalid-permissions.js +57 -0
- package/src/rules/no-missing-permissions.js +30 -0
- package/src/rules/no-remote-code.js +141 -0
- package/src/rules/no-unused-permissions.js +39 -0
- package/src/rules/require-mv3.js +21 -0
- package/src/rules/valid-mv3-schema.js +44 -0
- package/src/rules/valid-service-worker.js +43 -0
- package/src/utils.js +78 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { remoteCodePatterns } from '../data/manifest.js'
|
|
2
|
+
import { getMemberPath } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
const re = { url: /^https?:\/\// }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Disallow remote or dynamically-evaluated code, per the MV3 CSP',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
recommended: true
|
|
13
|
+
},
|
|
14
|
+
defaultOptions: [{
|
|
15
|
+
patterns: ['eval', 'new-function', 'string-timer',
|
|
16
|
+
'dynamic-import', 'remote-script-src', 'execute-script-url'],
|
|
17
|
+
checkExecuteScriptClosures: true
|
|
18
|
+
}],
|
|
19
|
+
schema: [{
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
patterns: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
items: { enum: remoteCodePatterns },
|
|
25
|
+
description: 'Detection paths to enable'
|
|
26
|
+
},
|
|
27
|
+
checkExecuteScriptClosures: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'Flag chrome.scripting.executeScript calls whose func captures outer scope'
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false
|
|
33
|
+
}],
|
|
34
|
+
messages: {
|
|
35
|
+
eval: '`eval()` is forbidden by the MV3 CSP.',
|
|
36
|
+
newFunction: '`new Function()` is forbidden by the MV3 CSP.',
|
|
37
|
+
stringTimer: 'String-argument `{{fn}}` is forbidden by the MV3 CSP - pass a function reference.',
|
|
38
|
+
dynamicImport: 'Remote dynamic `import()` is forbidden by the MV3 CSP.',
|
|
39
|
+
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden by the MV3 CSP - use `chrome.runtime.getURL()`.',
|
|
40
|
+
executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
|
|
41
|
+
executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization - pass them via `args`.'
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
create(context) {
|
|
46
|
+
const opts = context.options[0] || {},
|
|
47
|
+
patterns = new Set(opts.patterns || remoteCodePatterns),
|
|
48
|
+
checkClosures = opts.checkExecuteScriptClosures !== false,
|
|
49
|
+
scriptElems = new Set()
|
|
50
|
+
function isLiteralUrl(node) {
|
|
51
|
+
return node?.type == 'Literal'
|
|
52
|
+
&& typeof node.value == 'string'
|
|
53
|
+
&& re.url.test(node.value)
|
|
54
|
+
}
|
|
55
|
+
function isCreateScript(node) {
|
|
56
|
+
return node?.type == 'CallExpression'
|
|
57
|
+
&& node.callee?.property?.name == 'createElement'
|
|
58
|
+
&& node.arguments[0]?.value == 'script'
|
|
59
|
+
}
|
|
60
|
+
function isObjectAssign(node) {
|
|
61
|
+
return node?.type == 'CallExpression'
|
|
62
|
+
&& node.callee?.type == 'MemberExpression'
|
|
63
|
+
&& node.callee.object?.name == 'Object'
|
|
64
|
+
&& node.callee.property?.name == 'assign'
|
|
65
|
+
}
|
|
66
|
+
function isScriptSrcAssignment(left, right) {
|
|
67
|
+
if (left.type != 'MemberExpression') return false
|
|
68
|
+
if (left.property?.name != 'src') return false
|
|
69
|
+
if (!isLiteralUrl(right)) return false
|
|
70
|
+
if (isCreateScript(left.object)) return true
|
|
71
|
+
if (left.object.type == 'Identifier' && scriptElems.has(left.object.name)) return true
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
function isScriptSrcProperty(node) {
|
|
75
|
+
const obj = node.parent
|
|
76
|
+
if (obj?.type != 'ObjectExpression') return false
|
|
77
|
+
const parent = obj.parent
|
|
78
|
+
if (isCreateScript(parent)) return true
|
|
79
|
+
if (isObjectAssign(parent) && isCreateScript(parent.arguments[0])) return true
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
VariableDeclarator(node) {
|
|
84
|
+
if (isCreateScript(node.init) && node.id.type == 'Identifier')
|
|
85
|
+
scriptElems.add(node.id.name)
|
|
86
|
+
},
|
|
87
|
+
ImportExpression(node) {
|
|
88
|
+
if (!patterns.has('dynamic-import')) return
|
|
89
|
+
if (isLiteralUrl(node.source))
|
|
90
|
+
context.report({ node, messageId: 'dynamicImport' })
|
|
91
|
+
},
|
|
92
|
+
CallExpression(node) {
|
|
93
|
+
if (patterns.has('eval')
|
|
94
|
+
&& node.callee.type == 'Identifier'
|
|
95
|
+
&& node.callee.name == 'eval'
|
|
96
|
+
) context.report({ node, messageId: 'eval' })
|
|
97
|
+
if (patterns.has('string-timer')
|
|
98
|
+
&& node.callee.type == 'Identifier'
|
|
99
|
+
&& ['setTimeout', 'setInterval'].includes(node.callee.name)
|
|
100
|
+
&& node.arguments[0]?.type == 'Literal'
|
|
101
|
+
&& typeof node.arguments[0].value == 'string'
|
|
102
|
+
) context.report({ node, messageId: 'stringTimer', data: { fn: node.callee.name }})
|
|
103
|
+
if (patterns.has('dynamic-import')
|
|
104
|
+
&& node.callee.type == 'Import'
|
|
105
|
+
&& isLiteralUrl(node.arguments[0])
|
|
106
|
+
) context.report({ node, messageId: 'dynamicImport' })
|
|
107
|
+
if (node.callee.type == 'MemberExpression') {
|
|
108
|
+
const path = getMemberPath(node.callee)
|
|
109
|
+
if (path == 'chrome.scripting.executeScript') {
|
|
110
|
+
const optsNode = node.arguments[0]
|
|
111
|
+
if (optsNode?.type != 'ObjectExpression') return
|
|
112
|
+
const urlProp = optsNode.properties.find(prop => prop.key?.name == 'url'),
|
|
113
|
+
funcProp = optsNode.properties.find(prop => prop.key?.name == 'func'),
|
|
114
|
+
argsProp = optsNode.properties.find(prop => prop.key?.name == 'args')
|
|
115
|
+
if (patterns.has('execute-script-url') && urlProp)
|
|
116
|
+
context.report({ node: urlProp, messageId: 'executeScriptURL' })
|
|
117
|
+
if (checkClosures && funcProp && !argsProp)
|
|
118
|
+
context.report({ node: funcProp, messageId: 'executeScriptClosure' })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
NewExpression(node) {
|
|
123
|
+
if (patterns.has('new-function') && node.callee.name == 'Function')
|
|
124
|
+
context.report({ node, messageId: 'newFunction' })
|
|
125
|
+
},
|
|
126
|
+
AssignmentExpression(node) {
|
|
127
|
+
if (!patterns.has('remote-script-src')) return
|
|
128
|
+
if (isScriptSrcAssignment(node.left, node.right))
|
|
129
|
+
context.report({ node: node.right, messageId: 'remoteScriptSrc' })
|
|
130
|
+
},
|
|
131
|
+
Property(node) {
|
|
132
|
+
if (!patterns.has('remote-script-src')
|
|
133
|
+
|| node.key?.name != 'src'
|
|
134
|
+
|| !isLiteralUrl(node.value)
|
|
135
|
+
|| !isScriptSrcProperty(node)
|
|
136
|
+
) return
|
|
137
|
+
context.report({ node: node.value, messageId: 'remoteScriptSrc' })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { apiPermissionMap } from '../data/apis.js'
|
|
2
|
+
import { readManifest, getChromeAPIcalls } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
const mappedPermissions = new Set(apiPermissionMap.values())
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'suggestion',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Flag manifest permissions whose APIs are never used in this file',
|
|
11
|
+
category: 'Best Practices',
|
|
12
|
+
recommended: true
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
unusedPermission: 'Permission "{{permission}}" is declared but its APIs are never used.'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
22
|
+
return {
|
|
23
|
+
'Program:exit'() {
|
|
24
|
+
const declared = manifest.permissions || [] ; if (!declared.length) return
|
|
25
|
+
const calls = getChromeAPIcalls(context.sourceCode)
|
|
26
|
+
const used = new Set()
|
|
27
|
+
for (const api of calls)
|
|
28
|
+
for (const [prefix, permission] of apiPermissionMap)
|
|
29
|
+
if (api == prefix || api.startsWith(`${prefix}.`)) { used.add(permission) ; break }
|
|
30
|
+
for (const permission of declared) {
|
|
31
|
+
if (!mappedPermissions.has(permission)) continue
|
|
32
|
+
if (used.has(permission)) continue
|
|
33
|
+
context.report({ loc: { line: 1, column: 0 },
|
|
34
|
+
messageId: 'unusedPermission', data: { permission }})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readManifest } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'Require manifest_version 3',
|
|
8
|
+
category: 'Possible Errors',
|
|
9
|
+
recommended: true
|
|
10
|
+
},
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
notMV3: 'manifest.json must declare `"manifest_version": 3` (MV2 rejected by the Chrome Web Store).'
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
create(context) {
|
|
18
|
+
const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version == 3) return {}
|
|
19
|
+
return { Program(node) { context.report({ node, messageId: 'notMV3' }) }}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mv2OnlyKeys, forbiddenCSPDirectives } from '../data/manifest.js'
|
|
2
|
+
import { readManifest } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'Validate manifest against the MV3 schema',
|
|
9
|
+
category: 'Possible Errors',
|
|
10
|
+
recommended: true
|
|
11
|
+
},
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
mv2Key: '`{{key}}` is not allowed in MV3 - {{reason}}.',
|
|
15
|
+
unsafeCSP: 'MV3 CSP must not include `{{directive}}`.',
|
|
16
|
+
wrongCSPType: '`content_security_policy` must be an object in MV3, not a string.'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const manifest = readManifest(context) ; if (!manifest || manifest.manifest_version != 3) return {}
|
|
22
|
+
return {
|
|
23
|
+
Program(node) {
|
|
24
|
+
for (const key of Object.keys(manifest))
|
|
25
|
+
if (mv2OnlyKeys.has(key))
|
|
26
|
+
context.report({ node, messageId: 'mv2Key', data: { key, reason: 'MV2-only key' }})
|
|
27
|
+
if (manifest.background?.scripts)
|
|
28
|
+
context.report({ node, messageId: 'mv2Key',
|
|
29
|
+
data: { key: 'background.scripts', reason: 'use background.service_worker' }})
|
|
30
|
+
if (manifest.background?.persistent != null)
|
|
31
|
+
context.report({ node, messageId: 'mv2Key',
|
|
32
|
+
data: { key: 'background.persistent', reason: 'MV3 service workers are never persistent' }})
|
|
33
|
+
const csp = manifest.content_security_policy
|
|
34
|
+
if (csp != null && typeof csp == 'string')
|
|
35
|
+
context.report({ node, messageId: 'wrongCSPType' })
|
|
36
|
+
const cspPages = csp?.extension_pages
|
|
37
|
+
if (typeof cspPages == 'string')
|
|
38
|
+
for (const directive of forbiddenCSPDirectives)
|
|
39
|
+
if (cspPages.includes(directive))
|
|
40
|
+
context.report({ node, messageId: 'unsafeCSP', data: { directive }})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { domGlobals, serviceWorkerKeepaliveMinMS } from '../data/manifest.js'
|
|
2
|
+
import { readManifest, isServiceWorkerFile, isInsideNestedFunction } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'Enforce valid service worker lifecycle and isolation',
|
|
9
|
+
category: 'Possible Errors',
|
|
10
|
+
recommended: true
|
|
11
|
+
},
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level so it attaches on service worker wake-up.',
|
|
15
|
+
keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive - use `chrome.alarms` instead.',
|
|
16
|
+
domInSW: '`{{global}}` is not available in a service worker.'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const manifest = readManifest(context) ; if (!manifest || !isServiceWorkerFile(context, manifest)) return {}
|
|
22
|
+
return {
|
|
23
|
+
CallExpression(node) {
|
|
24
|
+
if (node.callee.type == 'MemberExpression'
|
|
25
|
+
&& node.callee.property.name == 'addListener'
|
|
26
|
+
&& isInsideNestedFunction(node)
|
|
27
|
+
) context.report({ node, messageId: 'listenerNotTopLevel' })
|
|
28
|
+
if (node.callee.type == 'Identifier'
|
|
29
|
+
&& ['setTimeout', 'setInterval'].includes(node.callee.name)
|
|
30
|
+
&& node.arguments[1]?.type == 'Literal'
|
|
31
|
+
&& typeof node.arguments[1].value == 'number'
|
|
32
|
+
&& node.arguments[1].value < serviceWorkerKeepaliveMinMS
|
|
33
|
+
) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
|
|
34
|
+
},
|
|
35
|
+
Identifier(node) {
|
|
36
|
+
if (node.parent?.type == 'MemberExpression' && node.parent.property === node) return
|
|
37
|
+
if (node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed) return
|
|
38
|
+
if (domGlobals.has(node.name))
|
|
39
|
+
context.report({ node, messageId: 'domInSW', data: { global: node.name }})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const re = { chromeAPI: /\bchrome(?:\.\w+)+/g }, // eslint-disable-line security/detect-unsafe-regex
|
|
5
|
+
manifestCache = new Map()
|
|
6
|
+
|
|
7
|
+
export function findExtensionRoot(context) {
|
|
8
|
+
let dir = path.dirname(getFilename(context))
|
|
9
|
+
const root = path.parse(dir).root
|
|
10
|
+
while (dir != root) {
|
|
11
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
12
|
+
if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
|
|
13
|
+
dir = path.dirname(dir)
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getChromeAPIcalls(sourceCode) {
|
|
19
|
+
const calls = new Set()
|
|
20
|
+
for (const match of sourceCode.getText().matchAll(re.chromeAPI))
|
|
21
|
+
calls.add(match[0])
|
|
22
|
+
return calls
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getFilename(context) { return context.filename || context.getFilename() }
|
|
26
|
+
|
|
27
|
+
export function getMemberPath(node) {
|
|
28
|
+
const parts = []
|
|
29
|
+
let cur = node
|
|
30
|
+
while (cur?.type == 'MemberExpression') {
|
|
31
|
+
if (cur.computed || cur.property.type != 'Identifier') return null
|
|
32
|
+
parts.unshift(cur.property.name)
|
|
33
|
+
cur = cur.object
|
|
34
|
+
}
|
|
35
|
+
if (cur?.type != 'Identifier') return null
|
|
36
|
+
parts.unshift(cur.name)
|
|
37
|
+
return parts.join('.')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isContentScriptFile(context, manifest) {
|
|
41
|
+
const scripts = (manifest?.content_scripts || []).flatMap(cs => cs.js || [])
|
|
42
|
+
if (!scripts.length) return false
|
|
43
|
+
const root = findExtensionRoot(context)
|
|
44
|
+
if (!root) return false
|
|
45
|
+
const filename = path.resolve(getFilename(context))
|
|
46
|
+
return scripts.some(s => filename == path.resolve(root, s))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isInsideNestedFunction(node) {
|
|
50
|
+
let cur = node.parent
|
|
51
|
+
while (cur) {
|
|
52
|
+
if (['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(cur.type))
|
|
53
|
+
return true
|
|
54
|
+
cur = cur.parent
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isServiceWorkerFile(context, manifest) {
|
|
60
|
+
const sw = manifest?.background?.service_worker
|
|
61
|
+
if (!sw) return false
|
|
62
|
+
const root = findExtensionRoot(context)
|
|
63
|
+
if (!root) return false
|
|
64
|
+
return path.resolve(getFilename(context)) == path.resolve(root, sw)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readManifest(context) {
|
|
68
|
+
const root = findExtensionRoot(context)
|
|
69
|
+
if (!root) return null
|
|
70
|
+
if (manifestCache.has(root)) return manifestCache.get(root)
|
|
71
|
+
let parsed
|
|
72
|
+
try {
|
|
73
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
74
|
+
parsed = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'))
|
|
75
|
+
} catch { parsed = null }
|
|
76
|
+
manifestCache.set(root, parsed)
|
|
77
|
+
return parsed
|
|
78
|
+
}
|