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.
@@ -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
+ }