eslint-plugin-chrome-extensions 1.0.1 → 2.1.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 +580 -80
- package/package.json +6 -5
- package/src/data/deprecations.js +10 -0
- package/src/data/manifest.js +24 -5
- package/src/index.js +41 -22
- package/src/rules/manifest-key-order.js +76 -0
- package/src/rules/no-content-style-leak.js +1 -1
- package/src/rules/no-duplicate-manifest-keys.js +36 -0
- package/src/rules/no-insecure-fetch.js +52 -0
- package/src/rules/no-insecure-messaging.js +44 -7
- package/src/rules/no-invalid-permissions.js +26 -5
- package/src/rules/no-missing-web-accessible-resources.js +51 -0
- package/src/rules/no-remote-code.js +11 -9
- package/src/rules/no-unrestricted-host-permissions.js +54 -0
- package/src/rules/no-unused-permissions.js +64 -19
- package/src/rules/require-mv3.js +21 -3
- package/src/rules/valid-manifest-icons.js +85 -0
- package/src/rules/valid-mv3-schema.js +54 -5
- package/src/rules/valid-service-worker.js +1 -1
- package/src/rules/valid-version-field.js +95 -0
- package/src/utils.js +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-chrome-extensions",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "ESLint rules for Chrome extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"chromium"
|
|
59
59
|
],
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"eslint": ">=9.
|
|
61
|
+
"eslint": ">=9.6.0",
|
|
62
|
+
"@eslint/json": ">=0.1.0"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@e18e/eslint-plugin": "^0.8.1",
|
|
@@ -66,11 +67,11 @@
|
|
|
66
67
|
"@eslint/json": "^2.1.0",
|
|
67
68
|
"@eslint/markdown": "^8.0.3",
|
|
68
69
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
69
|
-
"eslint": "^10.
|
|
70
|
-
"eslint-plugin-constructors": "^1.4.
|
|
70
|
+
"eslint": "^10.12.0",
|
|
71
|
+
"eslint-plugin-constructors": "^1.4.1",
|
|
71
72
|
"eslint-plugin-eslint-plugin": "^7.6.2",
|
|
72
73
|
"eslint-plugin-import-x": "^4.17.1",
|
|
73
|
-
"eslint-plugin-n": "^18.4.
|
|
74
|
+
"eslint-plugin-n": "^18.4.1",
|
|
74
75
|
"eslint-plugin-no-secrets": "^2.3.3",
|
|
75
76
|
"eslint-plugin-no-unsanitized": "^4.1.5",
|
|
76
77
|
"eslint-plugin-promise": "^7.3.0",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Delete a line to end support
|
|
2
|
+
|
|
3
|
+
export const bundles = { // ruleId: [bundleName, note]
|
|
4
|
+
'no-invalid-permissions': ['recommended'],
|
|
5
|
+
'require-mv3': ['recommended'],
|
|
6
|
+
'valid-mv3-schema': ['recommended'],
|
|
7
|
+
'valid-version-field': ['recommended']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default { bundles }
|
package/src/data/manifest.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import globals from 'globals'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export const
|
|
3
|
+
const serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
|
|
4
|
+
export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
|
|
5
|
+
|
|
6
|
+
export const forbiddenCSPdirectives = [
|
|
5
7
|
`'unsafe-eval'`, `'unsafe-inline'`, `'unsafe-hashes'`, `'wasm-unsafe-eval'`, 'http://', 'https://']
|
|
6
8
|
|
|
7
|
-
export const
|
|
9
|
+
export const manifestKeyOrder = [
|
|
10
|
+
// platform-required
|
|
11
|
+
'manifest_version', 'name', 'version',
|
|
12
|
+
// CWS-required
|
|
13
|
+
'description', 'icons',
|
|
14
|
+
// optional (alphabetical, per docs)
|
|
15
|
+
'action', 'background', 'chrome_settings_overrides', 'chrome_url_overrides',
|
|
16
|
+
'commands', 'content_scripts', 'content_security_policy',
|
|
17
|
+
'cross_origin_embedder_policy', 'cross_origin_opener_policy',
|
|
18
|
+
'declarative_net_request', 'default_locale', 'devtools_page', 'export',
|
|
19
|
+
'externally_connectable', 'homepage_url', 'host_permissions', 'import',
|
|
20
|
+
'incognito', 'key', 'mime_types_handler', 'minimum_chrome_version',
|
|
21
|
+
'oauth2', 'omnibox', 'optional_host_permissions', 'optional_permissions',
|
|
22
|
+
'options_page', 'options_ui', 'permissions', 'requirements', 'sandbox',
|
|
23
|
+
'short_name', 'side_panel', 'storage', 'tts_engine', 'update_url',
|
|
24
|
+
'version_name', 'web_accessible_resources'
|
|
25
|
+
]
|
|
8
26
|
|
|
9
|
-
const
|
|
10
|
-
export const domGlobals = new Set(Object.keys(globals.browser).filter(name => !serviceWorkerGlobals.has(name)))
|
|
27
|
+
export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
|
|
11
28
|
|
|
12
29
|
export const remoteCodePatterns = [
|
|
13
30
|
'eval', 'new-function', 'string-timer', 'dynamic-import', 'remote-script-src', 'execute-script-url']
|
|
31
|
+
|
|
32
|
+
export const serviceWorkerKeepaliveMinMS = 30000
|
package/src/index.js
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
|
+
import { bundles as oldBundles } from './data/deprecations.js'
|
|
2
|
+
|
|
3
|
+
import manifestKeyOrder from './rules/manifest-key-order.js'
|
|
1
4
|
import noContentStyleLeak from './rules/no-content-style-leak.js'
|
|
2
5
|
import noDeprecatedAPIs from './rules/no-deprecated-apis.js'
|
|
6
|
+
import noDuplicateManifestKeys from './rules/no-duplicate-manifest-keys.js'
|
|
7
|
+
import noInsecureFetch from './rules/no-insecure-fetch.js'
|
|
3
8
|
import noInsecureMessaging from './rules/no-insecure-messaging.js'
|
|
4
9
|
import noInvalidPermissions from './rules/no-invalid-permissions.js'
|
|
5
10
|
import noMissingPermissions from './rules/no-missing-permissions.js'
|
|
11
|
+
import noMissingWebAccessibleResources from './rules/no-missing-web-accessible-resources.js'
|
|
6
12
|
import noRemoteCode from './rules/no-remote-code.js'
|
|
13
|
+
import noUnrestrictedHostPermissions from './rules/no-unrestricted-host-permissions.js'
|
|
7
14
|
import noUnusedPermissions from './rules/no-unused-permissions.js'
|
|
8
15
|
import requireMV3 from './rules/require-mv3.js'
|
|
16
|
+
import validManifestIcons from './rules/valid-manifest-icons.js'
|
|
9
17
|
import validMV3schema from './rules/valid-mv3-schema.js'
|
|
10
18
|
import validServiceWorker from './rules/valid-service-worker.js'
|
|
19
|
+
import validVersionField from './rules/valid-version-field.js'
|
|
20
|
+
|
|
21
|
+
import { bundleRules, deprecatedFrom, initBundleDeprecation } from './utils.js'
|
|
11
22
|
|
|
12
23
|
const plugin = {
|
|
13
24
|
rules: {
|
|
14
|
-
'
|
|
15
|
-
'valid-mv3-schema': validMV3schema,
|
|
16
|
-
'no-deprecated-apis': noDeprecatedAPIs,
|
|
17
|
-
'no-remote-code': noRemoteCode,
|
|
18
|
-
'valid-service-worker': validServiceWorker,
|
|
25
|
+
'manifest-key-order': manifestKeyOrder,
|
|
19
26
|
'no-content-style-leak': noContentStyleLeak,
|
|
27
|
+
'no-deprecated-apis': noDeprecatedAPIs,
|
|
28
|
+
'no-duplicate-manifest-keys': noDuplicateManifestKeys,
|
|
29
|
+
'no-insecure-fetch': noInsecureFetch,
|
|
30
|
+
'no-insecure-messaging': noInsecureMessaging,
|
|
20
31
|
'no-invalid-permissions': noInvalidPermissions,
|
|
21
32
|
'no-missing-permissions': noMissingPermissions,
|
|
33
|
+
'no-missing-web-accessible-resources': noMissingWebAccessibleResources,
|
|
34
|
+
'no-remote-code': noRemoteCode,
|
|
35
|
+
'no-unrestricted-host-permissions': noUnrestrictedHostPermissions,
|
|
22
36
|
'no-unused-permissions': noUnusedPermissions,
|
|
23
|
-
'
|
|
37
|
+
'require-mv3': requireMV3,
|
|
38
|
+
'valid-manifest-icons': validManifestIcons,
|
|
39
|
+
'valid-mv3-schema': validMV3schema,
|
|
40
|
+
'valid-service-worker': validServiceWorker,
|
|
41
|
+
'valid-version-field': validVersionField
|
|
24
42
|
}
|
|
25
43
|
}
|
|
26
44
|
|
|
@@ -30,29 +48,30 @@ for (const [ruleId, rule] of Object.entries(plugin.rules))
|
|
|
30
48
|
plugin.configs = {
|
|
31
49
|
recommended: {
|
|
32
50
|
plugins: { 'chrome-extensions': plugin },
|
|
33
|
-
rules:
|
|
34
|
-
'chrome-extensions
|
|
35
|
-
'chrome-extensions/valid-mv3-schema': 'error',
|
|
36
|
-
'chrome-extensions/no-deprecated-apis': 'error',
|
|
37
|
-
'chrome-extensions/no-remote-code': 'error',
|
|
38
|
-
'chrome-extensions/valid-service-worker': 'error',
|
|
39
|
-
'chrome-extensions/no-invalid-permissions': 'error',
|
|
40
|
-
'chrome-extensions/no-missing-permissions': 'error',
|
|
41
|
-
'chrome-extensions/no-unused-permissions': 'error',
|
|
42
|
-
'chrome-extensions/no-insecure-messaging': 'error'
|
|
43
|
-
}
|
|
51
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.docs.recommended,
|
|
52
|
+
'chrome-extensions', deprecatedFrom(oldBundles, 'recommended'))
|
|
44
53
|
},
|
|
45
54
|
strict: {
|
|
46
55
|
plugins: { 'chrome-extensions': plugin },
|
|
47
|
-
rules:
|
|
48
|
-
.map(ruleId => [`chrome-extensions/${ruleId}`, 'error']))
|
|
56
|
+
rules: bundleRules(plugin.rules, () => true, 'chrome-extensions')
|
|
49
57
|
},
|
|
50
58
|
problem: {
|
|
51
59
|
plugins: { 'chrome-extensions': plugin },
|
|
52
|
-
rules:
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.type == 'problem', 'chrome-extensions')
|
|
61
|
+
},
|
|
62
|
+
security: {
|
|
63
|
+
plugins: { 'chrome-extensions': plugin },
|
|
64
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.docs.security,
|
|
65
|
+
'chrome-extensions', deprecatedFrom(oldBundles, 'security'))
|
|
66
|
+
},
|
|
67
|
+
manifest: {
|
|
68
|
+
plugins: { 'chrome-extensions': plugin },
|
|
69
|
+
rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest,
|
|
70
|
+
'chrome-extensions', deprecatedFrom(oldBundles, 'manifest'))
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
|
74
|
+
for (const entry of Object.entries(oldBundles))
|
|
75
|
+
initBundleDeprecation(plugin, entry, 'chrome-extensions')
|
|
76
|
+
|
|
58
77
|
export { plugin as default, plugin as chromeExtensions }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { manifestKeyOrder } from '../data/manifest.js'
|
|
2
|
+
|
|
3
|
+
import { getFilename } from '../utils.js'
|
|
4
|
+
|
|
5
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
6
|
+
|
|
7
|
+
function keyOf(member) {
|
|
8
|
+
const keyNode = member.name || member.key
|
|
9
|
+
return keyNode?.value ?? keyNode?.name
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function findViolation(members, keys) {
|
|
13
|
+
const ranks = new Map(keys.map((key, index) => [key, index])),
|
|
14
|
+
known = members.map(member => {
|
|
15
|
+
const key = keyOf(member)
|
|
16
|
+
return { member, key, rank: ranks.get(key) }
|
|
17
|
+
}).filter(item => item.rank !== undefined)
|
|
18
|
+
for (let i = 1; i < known.length; i++)
|
|
19
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
20
|
+
if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'layout',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Enforces a preferred order for manifest keys',
|
|
28
|
+
category: 'Stylistic Issues',
|
|
29
|
+
recommended: false,
|
|
30
|
+
manifest: true
|
|
31
|
+
},
|
|
32
|
+
fixable: 'code',
|
|
33
|
+
schema: [{
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
order: {
|
|
37
|
+
type: 'array', items: { type: 'string' },
|
|
38
|
+
description: 'Manifest keys in preferred order; unspecified keys are ignored'
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false
|
|
42
|
+
}],
|
|
43
|
+
defaultOptions: [{ order: [] }],
|
|
44
|
+
messages: { wrongOrder: '`{{first}}` should appear before `{{second}}`.' }
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
create(context) {
|
|
48
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
49
|
+
const custom = context.options[0]?.order ?? [],
|
|
50
|
+
master = custom.length ? manifestKeyOrder.filter(key => !custom.includes(key))
|
|
51
|
+
: manifestKeyOrder
|
|
52
|
+
return {
|
|
53
|
+
'Document:exit'(document) {
|
|
54
|
+
const root = document.body ; if (root?.type != 'Object') return
|
|
55
|
+
const members = root.members || root.properties || []
|
|
56
|
+
const violation = findViolation(members, master) ?? (custom.length && findViolation(members, custom))
|
|
57
|
+
if (!violation) return
|
|
58
|
+
const [first, second] = violation
|
|
59
|
+
context.report({
|
|
60
|
+
node: second.member, messageId: 'wrongOrder',
|
|
61
|
+
data: { first: second.key, second: first.key },
|
|
62
|
+
fix(fixer) {
|
|
63
|
+
const sourceCode = context.sourceCode,
|
|
64
|
+
firstText = sourceCode.text.slice(first.member.range[0], first.member.range[1]),
|
|
65
|
+
secondText = sourceCode.text.slice(second.member.range[0], second.member.range[1]),
|
|
66
|
+
between = sourceCode.text.slice(first.member.range[1], second.member.range[0])
|
|
67
|
+
return fixer.replaceTextRange(
|
|
68
|
+
[first.member.range[0], second.member.range[1]],
|
|
69
|
+
secondText + between + firstText
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -10,7 +10,7 @@ export default {
|
|
|
10
10
|
},
|
|
11
11
|
schema: [],
|
|
12
12
|
messages: {
|
|
13
|
-
styleLeak: 'Inject styles
|
|
13
|
+
styleLeak: 'Inject styles into a `shadowRoot` instead of the host document.'
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getFilename } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow duplicate keys in manifest.json',
|
|
10
|
+
category: 'Possible Errors',
|
|
11
|
+
recommended: false,
|
|
12
|
+
manifest: true
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
duplicateKey: 'Duplicate manifest key "{{key}}".'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
22
|
+
return {
|
|
23
|
+
Object(node) {
|
|
24
|
+
const members = node.members || node.properties || [],
|
|
25
|
+
seen = new Set()
|
|
26
|
+
for (const member of members) {
|
|
27
|
+
const keyNode = member.name || member.key ; if (!keyNode) continue
|
|
28
|
+
const keyName = keyNode.value ?? keyNode.name ; if (typeof keyName != 'string') continue
|
|
29
|
+
if (seen.has(keyName))
|
|
30
|
+
context.report({ node: keyNode, messageId: 'duplicateKey', data: { key: keyName } })
|
|
31
|
+
else seen.add(keyName)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const re = { insecureURL: /^http:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])/i }
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'Flags `fetch()` and `XMLHttpRequest.open()` calls that use `http://`',
|
|
8
|
+
category: 'Possible Errors',
|
|
9
|
+
recommended: true,
|
|
10
|
+
security: true
|
|
11
|
+
},
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
insecureFetch: '`fetch()` to "{{url}}" insecure. Use HTTPS or a relative path instead.',
|
|
15
|
+
insecureXHR: '`XMLHttpRequest.open()` to "{{url}}" insecure. Use HTTPS or a relative path instead.'
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
create(context) {
|
|
20
|
+
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
|
+
return {
|
|
28
|
+
VariableDeclarator(node) {
|
|
29
|
+
if (node.init?.type == 'NewExpression'
|
|
30
|
+
&& node.init.callee?.name == 'XMLHttpRequest'
|
|
31
|
+
&& node.id.type == 'Identifier'
|
|
32
|
+
) xhrVars.add(node.id.name)
|
|
33
|
+
},
|
|
34
|
+
CallExpression(node) {
|
|
35
|
+
if (node.callee.type == 'Identifier' && node.callee.name == 'fetch') {
|
|
36
|
+
const url = urlFromLiteral(node.arguments[0])
|
|
37
|
+
if (url)
|
|
38
|
+
context.report({ node: node.arguments[0], messageId: 'insecureFetch', data: { url }})
|
|
39
|
+
}
|
|
40
|
+
if (node.callee.type == 'MemberExpression'
|
|
41
|
+
&& node.callee.property?.name == 'open'
|
|
42
|
+
&& node.callee.object?.type == 'Identifier'
|
|
43
|
+
&& xhrVars.has(node.callee.object.name)
|
|
44
|
+
) {
|
|
45
|
+
const url = urlFromLiteral(node.arguments[1])
|
|
46
|
+
if (url)
|
|
47
|
+
context.report({ node: node.arguments[1], messageId: 'insecureXHR', data: { url }})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,24 +1,46 @@
|
|
|
1
|
-
import { isExternalMessagingListener } from '../data/apis.js'
|
|
2
1
|
import { getMemberPath } from '../utils.js'
|
|
2
|
+
import { isExternalMessagingListener } from '../data/apis.js'
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ }
|
|
5
5
|
|
|
6
6
|
export default {
|
|
7
7
|
meta: {
|
|
8
8
|
type: 'problem',
|
|
9
9
|
docs: {
|
|
10
|
-
description: '
|
|
10
|
+
description: 'Requires sender/origin validation + filtered `tabs.query()` + explicit `window.postMessage()` target',
|
|
11
11
|
category: 'Possible Errors',
|
|
12
|
-
recommended: true
|
|
12
|
+
recommended: true,
|
|
13
|
+
security: true
|
|
13
14
|
},
|
|
14
|
-
|
|
15
|
+
defaultOptions: [{ disallowWildcardTarget: true }],
|
|
16
|
+
schema: [{
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
disallowWildcardTarget: {
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
description: 'Flag postMessage calls that use "*" as targetOrigin'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
additionalProperties: false
|
|
25
|
+
}],
|
|
15
26
|
messages: {
|
|
16
27
|
unvalidatedExternal: '`{{listener}}` handler must validate `sender.<id|origin>` before processing messages.',
|
|
17
|
-
unfilteredBroadcast: '`chrome.tabs.query({})`
|
|
28
|
+
unfilteredBroadcast: '`chrome.tabs.query({})` w/ no filter broadcasts to every open tab. Add `url` or `currentWindow` instead.',
|
|
29
|
+
unvalidatedPostMessage: '`{{target}}` message handler must validate `event.origin`/`event.source` before processing messages.',
|
|
30
|
+
wildcardTarget: '`postMessage` to `"*"` leaks data to any origin. Explicitly specify `targetOrigin` instead.'
|
|
18
31
|
}
|
|
19
32
|
},
|
|
20
33
|
|
|
21
34
|
create(context) {
|
|
35
|
+
const opts = context.options[0] || {},
|
|
36
|
+
disallowWildcard = opts.disallowWildcardTarget !== false
|
|
37
|
+
function checkPostMessageHandler(node, handlerNode, target) {
|
|
38
|
+
if (!handlerNode) return
|
|
39
|
+
const body = context.sourceCode.getText(handlerNode)
|
|
40
|
+
if (re.postmsgValidation.test(body)) return
|
|
41
|
+
context.report({ node, messageId: 'unvalidatedPostMessage', data: { target }})
|
|
42
|
+
}
|
|
43
|
+
function isMessageTarget(obj) { return obj?.type == 'Identifier' && ['self', 'window'].includes(obj.name) }
|
|
22
44
|
return {
|
|
23
45
|
CallExpression(node) {
|
|
24
46
|
if (node.callee.type != 'MemberExpression') return
|
|
@@ -27,7 +49,7 @@ export default {
|
|
|
27
49
|
if (listenerRoot && isExternalMessagingListener(listenerRoot)) {
|
|
28
50
|
const handler = node.arguments[0] ; if (!handler) return
|
|
29
51
|
const body = context.sourceCode.getText(handler)
|
|
30
|
-
if (!
|
|
52
|
+
if (!re.senderValidation.test(body))
|
|
31
53
|
context.report({ node, messageId: 'unvalidatedExternal', data: { listener: listenerRoot }})
|
|
32
54
|
}
|
|
33
55
|
if (path == 'chrome.tabs.query') {
|
|
@@ -36,6 +58,21 @@ export default {
|
|
|
36
58
|
if (isEmpty)
|
|
37
59
|
context.report({ node, messageId: 'unfilteredBroadcast' })
|
|
38
60
|
}
|
|
61
|
+
if (node.callee.property?.name == 'addEventListener'
|
|
62
|
+
&& node.arguments[0]?.value == 'message'
|
|
63
|
+
&& isMessageTarget(node.callee.object)
|
|
64
|
+
) checkPostMessageHandler(node, node.arguments[1], node.callee.object.name)
|
|
65
|
+
if (disallowWildcard && node.callee.property?.name == 'postMessage') {
|
|
66
|
+
const target = node.arguments[1]
|
|
67
|
+
if (target?.type == 'Literal' && target.value == '*')
|
|
68
|
+
context.report({ node: target, messageId: 'wildcardTarget' })
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
AssignmentExpression(node) {
|
|
72
|
+
if (node.left.type == 'MemberExpression'
|
|
73
|
+
&& node.left.property?.name == 'onmessage'
|
|
74
|
+
&& isMessageTarget(node.left.object)
|
|
75
|
+
) checkPostMessageHandler(node, node.right, node.left.object.name)
|
|
39
76
|
}
|
|
40
77
|
}
|
|
41
78
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { getFilename, readManifest } from '../utils.js'
|
|
1
2
|
import { isValidPermission } from '../data/permissions.js'
|
|
2
|
-
|
|
3
|
+
|
|
4
|
+
const re = { manifestFilename: /manifest\.json$/ }
|
|
3
5
|
|
|
4
6
|
export default {
|
|
5
7
|
meta: {
|
|
@@ -7,7 +9,8 @@ export default {
|
|
|
7
9
|
docs: {
|
|
8
10
|
description: 'Disallow invalid or unsupported manifest permission names',
|
|
9
11
|
category: 'Possible Errors',
|
|
10
|
-
recommended:
|
|
12
|
+
recommended: false,
|
|
13
|
+
manifest: true
|
|
11
14
|
},
|
|
12
15
|
defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],
|
|
13
16
|
schema: [{
|
|
@@ -40,9 +43,27 @@ export default {
|
|
|
40
43
|
const opts = context.options[0] || {},
|
|
41
44
|
target = opts.target || 'mv3',
|
|
42
45
|
allowed = new Set(Array.isArray(opts.allowedPermissions) ? opts.allowedPermissions
|
|
43
|
-
: opts.allowedPermissions ? [opts.allowedPermissions] : [])
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
: opts.allowedPermissions ? [opts.allowedPermissions] : [])
|
|
47
|
+
function checkPermission(node, permission) {
|
|
48
|
+
if (typeof permission != 'string') return
|
|
49
|
+
if (allowed.has(permission) || isValidPermission(permission, target)) return
|
|
50
|
+
context.report({ node, messageId: 'invalidPermission', data: { permission, target }})
|
|
51
|
+
}
|
|
52
|
+
if (re.manifestFilename.test(getFilename(context))) // lint it directly
|
|
53
|
+
return {
|
|
54
|
+
'*'(node) {
|
|
55
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
56
|
+
const keyName = keyNode.value ?? keyNode.name
|
|
57
|
+
if (keyName != 'permissions' && keyName != 'optional_permissions') return
|
|
58
|
+
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
59
|
+
const elements = arrNode.elements || arrNode.items || []
|
|
60
|
+
for (const el of elements) {
|
|
61
|
+
const valueNode = el.value || el
|
|
62
|
+
checkPermission(valueNode, valueNode?.value)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
46
67
|
const all = [...(manifest.permissions || []), ...(manifest.optional_permissions || [])]
|
|
47
68
|
return {
|
|
48
69
|
'Program:exit'() {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getMemberPath, isContentScriptFile, readManifest } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const re = { leadingSlash: /^\//, regexSpecial: /[.+?^${}()|[\]\\]/g }
|
|
4
|
+
|
|
5
|
+
function collectWarPatterns(manifest) {
|
|
6
|
+
const war = manifest.web_accessible_resources
|
|
7
|
+
if (!Array.isArray(war)) return []
|
|
8
|
+
const patterns = []
|
|
9
|
+
for (const entry of war) {
|
|
10
|
+
if (typeof entry == 'string') patterns.push(entry) // MV2 flat
|
|
11
|
+
else if (Array.isArray(entry?.resources))
|
|
12
|
+
for (const res of entry.resources) patterns.push(res) // MV3 object
|
|
13
|
+
}
|
|
14
|
+
return patterns
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function patternToRegex(pattern) {
|
|
18
|
+
const escaped = pattern.replace(re.regexSpecial, '\\$&').replace(/\*/g, '.*')
|
|
19
|
+
return new RegExp(`^${escaped}$`) // eslint-disable-line security/detect-non-literal-regexp
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
meta: {
|
|
24
|
+
type: 'problem',
|
|
25
|
+
docs: {
|
|
26
|
+
description: 'Require content-script getURL() paths to be listed in `web_accessible_resources`',
|
|
27
|
+
category: 'Possible Errors',
|
|
28
|
+
recommended: true
|
|
29
|
+
},
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: {
|
|
32
|
+
missingWar: '`{{path}}` is not in `web_accessible_resources`.'
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create(context) {
|
|
37
|
+
const manifest = readManifest(context) ; if (!manifest) return {}
|
|
38
|
+
if (!isContentScriptFile(context, manifest)) return {}
|
|
39
|
+
const regexes = collectWarPatterns(manifest).map(patternToRegex)
|
|
40
|
+
return {
|
|
41
|
+
CallExpression(node) {
|
|
42
|
+
if (node.callee.type != 'MemberExpression') return
|
|
43
|
+
if (getMemberPath(node.callee) != 'chrome.runtime.getURL') return
|
|
44
|
+
const arg = node.arguments[0] ; if (arg?.type != 'Literal' || typeof arg.value != 'string') return
|
|
45
|
+
const resourcePath = arg.value.replace(re.leadingSlash, '')
|
|
46
|
+
for (const regex of regexes) if (regex.test(resourcePath)) return
|
|
47
|
+
context.report({ node: arg, messageId: 'missingWar', data: { path: arg.value }})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { remoteCodePatterns } from '../data/manifest.js'
|
|
2
|
+
|
|
2
3
|
import { getMemberPath } from '../utils.js'
|
|
3
4
|
|
|
4
5
|
const re = { url: /^https?:\/\// }
|
|
@@ -7,9 +8,10 @@ export default {
|
|
|
7
8
|
meta: {
|
|
8
9
|
type: 'problem',
|
|
9
10
|
docs: {
|
|
10
|
-
description: 'Disallow remote or dynamically-evaluated code, per
|
|
11
|
+
description: 'Disallow remote or dynamically-evaluated code, per MV3\'s CSP',
|
|
11
12
|
category: 'Possible Errors',
|
|
12
|
-
recommended: true
|
|
13
|
+
recommended: true,
|
|
14
|
+
security: true
|
|
13
15
|
},
|
|
14
16
|
defaultOptions: [{
|
|
15
17
|
patterns: ['eval', 'new-function', 'string-timer',
|
|
@@ -26,19 +28,19 @@ export default {
|
|
|
26
28
|
},
|
|
27
29
|
checkExecuteScriptClosures: {
|
|
28
30
|
type: 'boolean',
|
|
29
|
-
description: 'Flag chrome.scripting.executeScript calls whose func captures outer scope'
|
|
31
|
+
description: 'Flag `chrome.scripting.executeScript` calls whose func captures outer scope'
|
|
30
32
|
}
|
|
31
33
|
},
|
|
32
34
|
additionalProperties: false
|
|
33
35
|
}],
|
|
34
36
|
messages: {
|
|
35
|
-
eval: '`eval()` is forbidden
|
|
36
|
-
newFunction: '`new Function()` is forbidden
|
|
37
|
-
stringTimer: 'String-argument `{{fn}}` is forbidden
|
|
38
|
-
dynamicImport: 'Remote dynamic `import()` is forbidden
|
|
39
|
-
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden
|
|
37
|
+
eval: '`eval()` is forbidden under MV3\'s CSP. Use static code instead.',
|
|
38
|
+
newFunction: '`new Function()` is forbidden under MV3\'s CSP. Use a real function instead.',
|
|
39
|
+
stringTimer: 'String-argument `{{fn}}` is forbidden under MV3\'s CSP. Pass a function reference instead.',
|
|
40
|
+
dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP. Use a relative path instead.',
|
|
41
|
+
remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP. Use `chrome.runtime.getURL()` for bundled scripts instead.',
|
|
40
42
|
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
|
|
43
|
+
executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.'
|
|
42
44
|
}
|
|
43
45
|
},
|
|
44
46
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getFilename } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const re = { broadHost: /^(?:<all_urls>|\*:\/\/\*\/\*|\w+:\/\/\*\/\*)$/, manifestFilename: /manifest\.json$/ },
|
|
4
|
+
hostKeys = new Set(['host_permissions', 'optional_host_permissions', 'optional_permissions', 'permissions'])
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Disallow broad host permissions that grant access to every site',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
recommended: false,
|
|
13
|
+
manifest: true
|
|
14
|
+
},
|
|
15
|
+
defaultOptions: [{ allowedPatterns: [] }],
|
|
16
|
+
schema: [{
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
allowedPatterns: {
|
|
20
|
+
type: 'array',
|
|
21
|
+
items: { type: 'string' },
|
|
22
|
+
description: 'Host patterns to explicitly allow despite being broad'
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
additionalProperties: false
|
|
26
|
+
}],
|
|
27
|
+
messages: {
|
|
28
|
+
unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. Narrow to specific origins or allow explicitly instead.'
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
create(context) {
|
|
33
|
+
if (!re.manifestFilename.test(getFilename(context))) return {}
|
|
34
|
+
const allowed = new Set(context.options[0]?.allowedPatterns || [])
|
|
35
|
+
return {
|
|
36
|
+
'*'(node) {
|
|
37
|
+
const keyNode = node.name || node.key ; if (!keyNode) return
|
|
38
|
+
const keyName = keyNode.value ?? keyNode.name
|
|
39
|
+
if (!hostKeys.has(keyName)) return
|
|
40
|
+
const arrNode = node.value ; if (arrNode?.type != 'Array') return
|
|
41
|
+
const elements = arrNode.elements || arrNode.items || []
|
|
42
|
+
for (const el of elements) {
|
|
43
|
+
const valueNode = el.value || el,
|
|
44
|
+
pattern = valueNode?.value
|
|
45
|
+
if (typeof pattern != 'string'
|
|
46
|
+
|| allowed.has(pattern)
|
|
47
|
+
|| !re.broadHost.test(pattern)
|
|
48
|
+
) continue
|
|
49
|
+
context.report({ node: valueNode, messageId: 'unrestrictedHost', data: { pattern }})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|