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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "1.0.1",
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.0.0"
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.11.0",
70
- "eslint-plugin-constructors": "^1.4.0",
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.0",
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 }
@@ -1,13 +1,32 @@
1
1
  import globals from 'globals'
2
2
 
3
- export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
4
- export const forbiddenCSPDirectives = [
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 serviceWorkerKeepaliveMinMS = 30000
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 serviceWorkerGlobals = new Set(Object.keys(globals.serviceworker || {}))
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
- 'require-mv3': requireMV3,
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
- 'no-insecure-messaging': noInsecureMessaging
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/require-mv3': 'error',
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: Object.fromEntries(Object.keys(plugin.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: Object.fromEntries(Object.entries(plugin.rules)
53
- .filter(([, rule]) => rule.meta.type == 'problem')
54
- .map(([ruleId]) => [`chrome-extensions/${ruleId}`, 'error']))
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 via `chrome.scripting.insertCSS()` or `content_scripts.css` instead of appending to the host document.'
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 RE_SENDER_VALIDATION = /sender\.(?:id|origin)/
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: 'Require sender validation on external messaging + filtered tab broadcasts',
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
- schema: [],
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({})` with no filter broadcasts to every open tab - add `url` or `currentWindow`.'
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 (!RE_SENDER_VALIDATION.test(body))
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
- import { readManifest } from '../utils.js'
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: true
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
- manifest = readManifest(context)
45
- if (!manifest) return {}
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 the MV3 CSP',
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 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()`.',
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 - pass them via `args`.'
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
+ }