eslint-plugin-chrome-extensions 2.0.0 → 2.2.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/docs/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🏛️ MIT License
2
2
 
3
- **Copyright © 2026 [Adam Lui](https://codeberg.org/adamlui).**
3
+ **Copyright © 2026 [Adam Lui](https://codeberg.org/adamlui)**
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-chrome-extensions",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "ESLint rules for Chrome extensions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,7 +34,6 @@
34
34
  ],
35
35
  "types": "src/index.d.ts",
36
36
  "scripts": {
37
- "prepare": "husky",
38
37
  "lint": "eslint . --cache && yamllint .",
39
38
  "lint:all": "eslint . && yamllint .",
40
39
  "lint:fix": "eslint . --fix --cache && yamllint .",
@@ -64,11 +63,11 @@
64
63
  "devDependencies": {
65
64
  "@e18e/eslint-plugin": "^0.8.1",
66
65
  "@eslint/js": "^10.0.1",
67
- "@eslint/json": "^2.1.0",
66
+ "@eslint/json": "^2.1.1",
68
67
  "@eslint/markdown": "^8.0.3",
69
68
  "@stylistic/eslint-plugin": "^5.10.0",
70
69
  "eslint": "^10.12.0",
71
- "eslint-plugin-constructors": "^1.4.0",
70
+ "eslint-plugin-constructors": "^1.4.2",
72
71
  "eslint-plugin-eslint-plugin": "^7.6.2",
73
72
  "eslint-plugin-import-x": "^4.17.1",
74
73
  "eslint-plugin-n": "^18.4.1",
@@ -77,7 +76,6 @@
77
76
  "eslint-plugin-promise": "^7.3.0",
78
77
  "eslint-plugin-regexp": "^3.3.1",
79
78
  "eslint-plugin-security": "^4.2.0",
80
- "husky": "^9.1.7",
81
79
  "yamllint-js": "^0.2.4"
82
80
  },
83
81
  "dependencies": {
package/src/data/apis.js CHANGED
@@ -33,6 +33,7 @@ export const apiPermissionMap = new Map([
33
33
  ['chrome.history', 'history'],
34
34
  ['chrome.idle', 'idle'],
35
35
  ['chrome.management', 'management'],
36
+ ['chrome.menus', 'menus'],
36
37
  ['chrome.notifications', 'notifications'],
37
38
  ['chrome.pageCapture', 'pageCapture'],
38
39
  ['chrome.privacy', 'privacy'],
@@ -41,10 +42,60 @@ export const apiPermissionMap = new Map([
41
42
  ['chrome.sessions', 'sessions'],
42
43
  ['chrome.storage', 'storage'],
43
44
  ['chrome.tabCapture', 'tabCapture'],
45
+ ['chrome.tabs.query', 'tabs'],
44
46
  ['chrome.topSites', 'topSites'],
45
47
  ['chrome.webRequest', 'webRequest']
46
48
  ])
47
49
 
50
+ export const permissionlessAPIs = new Set([
51
+ // https://developer.chrome.com/docs/extensions/reference/api/management
52
+ 'chrome.management.getPermissionWarningsByManifest',
53
+ 'chrome.management.getSelf',
54
+ 'chrome.management.uninstallSelf',
55
+ // https://developer.chrome.com/docs/extensions/reference/api/runtime
56
+ 'chrome.runtime.connect',
57
+ 'chrome.runtime.getManifest',
58
+ 'chrome.runtime.getPlatformInfo',
59
+ 'chrome.runtime.getURL',
60
+ 'chrome.runtime.onBrowserUpdateAvailable', // eslint-disable-line no-secrets/no-secrets
61
+ 'chrome.runtime.onConnect',
62
+ 'chrome.runtime.onConnectExternal',
63
+ 'chrome.runtime.onInstalled',
64
+ 'chrome.runtime.onMessage',
65
+ 'chrome.runtime.onMessageExternal',
66
+ 'chrome.runtime.onStartup',
67
+ 'chrome.runtime.onSuspend',
68
+ 'chrome.runtime.onSuspendCanceled',
69
+ 'chrome.runtime.onUpdateAvailable',
70
+ 'chrome.runtime.openOptionsPage',
71
+ 'chrome.runtime.reload',
72
+ 'chrome.runtime.requestUpdateCheck',
73
+ 'chrome.runtime.restart',
74
+ 'chrome.runtime.restartAfterDelay',
75
+ 'chrome.runtime.sendMessage',
76
+ 'chrome.runtime.setUninstallURL',
77
+ // https://developer.chrome.com/docs/extensions/reference/api/tabs
78
+ 'chrome.tabs.captureVisibleTab',
79
+ 'chrome.tabs.connect',
80
+ 'chrome.tabs.create',
81
+ 'chrome.tabs.detectLanguage',
82
+ 'chrome.tabs.discard',
83
+ 'chrome.tabs.duplicate',
84
+ 'chrome.tabs.executeScript',
85
+ 'chrome.tabs.get',
86
+ 'chrome.tabs.getCurrent',
87
+ 'chrome.tabs.goBack',
88
+ 'chrome.tabs.goForward',
89
+ 'chrome.tabs.highlight',
90
+ 'chrome.tabs.insertCSS',
91
+ 'chrome.tabs.move',
92
+ 'chrome.tabs.reload',
93
+ 'chrome.tabs.remove',
94
+ 'chrome.tabs.removeCSS',
95
+ 'chrome.tabs.sendMessage',
96
+ 'chrome.tabs.update'
97
+ ])
98
+
48
99
  export const externalMessagingListeners =
49
100
  new Set(['chrome.runtime.onMessageExternal', 'chrome.runtime.onConnectExternal'])
50
101
 
@@ -56,6 +107,7 @@ export function findDeprecated(api) {
56
107
  }
57
108
 
58
109
  export function findPermission(api) {
110
+ if (permissionlessAPIs.has(api)) return null
59
111
  for (const [prefix, permission] of apiPermissionMap)
60
112
  if (api == prefix || api.startsWith(`${prefix}.`))
61
113
  return permission
@@ -0,0 +1,10 @@
1
+ // Delete a line to end support
2
+
3
+ export const bundles = {
4
+ 'no-invalid-permissions': ['recommended'], // 2026-10-5 announced future removal
5
+ 'require-mv3': ['recommended'], // 2026-10-5 announced future removal
6
+ 'valid-mv3-schema': ['recommended'], // 2026-10-5 announced future removal
7
+ 'valid-version-field': ['recommended'] // 2026-10-5 announced future removal
8
+ }
9
+
10
+ export default { bundles }
@@ -11,19 +11,30 @@ export const manifestKeyOrder = [
11
11
  'manifest_version', 'name', 'version',
12
12
  // CWS-required
13
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'
14
+ // optional
15
+ 'action', 'author', 'background', 'browser_specific_settings', 'chrome_settings_overrides', 'chrome_url_overrides',
16
+ 'commands', 'content_scripts', 'content_security_policy', 'cross_origin_embedder_policy',
17
+ 'cross_origin_opener_policy', 'dark_theme', 'declarative_net_request', 'default_locale', 'developer',
18
+ 'devtools_page', 'dictionaries', 'export', 'externally_connectable', 'homepage_url', 'host_permissions', 'import',
19
+ 'incognito', 'key', 'mime_types_handler', 'minimum_chrome_version', 'oauth2', 'omnibox',
20
+ 'optional_host_permissions', 'optional_permissions', 'options_page', 'options_ui', 'permissions',
21
+ 'protocol_handlers', 'requirements', 'sandbox', 'short_name', 'sidebar_action', 'side_panel', 'storage', 'theme',
22
+ 'theme_experiment', 'tts_engine', 'update_url', 'version_name', 'web_accessible_resources'
25
23
  ]
26
24
 
25
+ export const ffDataCollectionRequired = new Set([
26
+ 'none', 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo',
27
+ 'locationInfo', 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'websiteActivity',
28
+ 'websiteContent'
29
+ ])
30
+
31
+ export const ffDataCollectionOptional = new Set([
32
+ 'authenticationInfo', 'bookmarksInfo', 'browsingActivity', 'financialAndPaymentInfo', 'healthInfo', 'locationInfo',
33
+ 'personalCommunications', 'personallyIdentifyingInfo', 'searchTerms', 'technicalAndInteraction', 'websiteActivity',
34
+ 'websiteContent'
35
+ ])
36
+
37
+ export const ffMV3bgKeys = new Set(['scripts', 'page'])
27
38
  export const mv2OnlyKeys = new Set(['browser_action', 'page_action'])
28
39
 
29
40
  export const remoteCodePatterns = [
@@ -16,7 +16,7 @@ export const validPermissions = new Set([
16
16
  'downloads', 'downloads.open', 'downloads.ui', 'enterprise.deviceAttributes', 'enterprise.hardwarePlatform',
17
17
  'enterprise.networkingAttributes', 'enterprise.platformKeys', 'favicon', 'fileBrowserHandler',
18
18
  'fileSystemProvider', 'fontSettings', 'gcm', 'geolocation', 'history', 'identity', 'identity.email', 'idle',
19
- 'loginState', 'management', 'nativeMessaging', 'notifications', 'offscreen', 'pageCapture', 'platformKeys',
19
+ 'loginState', 'management', 'menus', 'nativeMessaging', 'notifications', 'offscreen', 'pageCapture', 'platformKeys',
20
20
  'power', 'printerProvider', 'printing', 'printingMetrics', 'privacy', 'processes', 'proxy', 'publicSuffix',
21
21
  'readingList', 'runtime', 'scripting', 'search', 'sessions', 'sidePanel', 'storage', 'system.cpu',
22
22
  'system.display', 'system.memory', 'system.storage', 'tabCapture', 'tabGroups', 'tabs', 'topSites', 'tts',
package/src/index.js CHANGED
@@ -1,29 +1,43 @@
1
- import { bundleRules } from './utils.js'
1
+ import { bundles as oldBundles } from './data/deprecations.js'
2
2
 
3
3
  import manifestKeyOrder from './rules/manifest-key-order.js'
4
+ import manifestSpacing from './rules/manifest-spacing.js'
4
5
  import noContentStyleLeak from './rules/no-content-style-leak.js'
5
6
  import noDeprecatedAPIs from './rules/no-deprecated-apis.js'
7
+ import noDuplicateManifestKeys from './rules/no-duplicate-manifest-keys.js'
8
+ import noInsecureFetch from './rules/no-insecure-fetch.js'
6
9
  import noInsecureMessaging from './rules/no-insecure-messaging.js'
7
10
  import noInvalidPermissions from './rules/no-invalid-permissions.js'
8
11
  import noMissingPermissions from './rules/no-missing-permissions.js'
12
+ import noMissingWebAccessibleResources from './rules/no-missing-web-accessible-resources.js'
9
13
  import noRemoteCode from './rules/no-remote-code.js'
14
+ import noUnrestrictedHostPermissions from './rules/no-unrestricted-host-permissions.js'
10
15
  import noUnusedPermissions from './rules/no-unused-permissions.js'
11
16
  import requireMV3 from './rules/require-mv3.js'
17
+ import validManifestIcons from './rules/valid-manifest-icons.js'
12
18
  import validMV3schema from './rules/valid-mv3-schema.js'
13
19
  import validServiceWorker from './rules/valid-service-worker.js'
14
20
  import validVersionField from './rules/valid-version-field.js'
15
21
 
22
+ import { bundleRules, initBundleDeprecation } from './utils.js'
23
+
16
24
  const plugin = {
17
25
  rules: {
18
26
  'manifest-key-order': manifestKeyOrder,
27
+ 'manifest-spacing': manifestSpacing,
19
28
  'no-content-style-leak': noContentStyleLeak,
20
29
  'no-deprecated-apis': noDeprecatedAPIs,
30
+ 'no-duplicate-manifest-keys': noDuplicateManifestKeys,
31
+ 'no-insecure-fetch': noInsecureFetch,
21
32
  'no-insecure-messaging': noInsecureMessaging,
22
33
  'no-invalid-permissions': noInvalidPermissions,
23
34
  'no-missing-permissions': noMissingPermissions,
35
+ 'no-missing-web-accessible-resources': noMissingWebAccessibleResources,
24
36
  'no-remote-code': noRemoteCode,
37
+ 'no-unrestricted-host-permissions': noUnrestrictedHostPermissions,
25
38
  'no-unused-permissions': noUnusedPermissions,
26
39
  'require-mv3': requireMV3,
40
+ 'valid-manifest-icons': validManifestIcons,
27
41
  'valid-mv3-schema': validMV3schema,
28
42
  'valid-service-worker': validServiceWorker,
29
43
  'valid-version-field': validVersionField
@@ -46,10 +60,17 @@ plugin.configs = {
46
60
  plugins: { 'chrome-extensions': plugin },
47
61
  rules: bundleRules(plugin.rules, rule => rule.meta.type == 'problem', 'chrome-extensions')
48
62
  },
63
+ security: {
64
+ plugins: { 'chrome-extensions': plugin },
65
+ rules: bundleRules(plugin.rules, rule => rule.meta.docs.security, 'chrome-extensions')
66
+ },
49
67
  manifest: {
50
68
  plugins: { 'chrome-extensions': plugin },
51
69
  rules: bundleRules(plugin.rules, rule => rule.meta.docs.manifest, 'chrome-extensions')
52
70
  }
53
71
  }
54
72
 
73
+ for (const entry of Object.entries(oldBundles))
74
+ initBundleDeprecation(plugin, entry, 'chrome-extensions')
75
+
55
76
  export { plugin as default, plugin as chromeExtensions }
@@ -1,4 +1,5 @@
1
1
  import { manifestKeyOrder } from '../data/manifest.js'
2
+
2
3
  import { getFilename } from '../utils.js'
3
4
 
4
5
  const re = { manifestFilename: /manifest\.json$/ }
@@ -23,7 +24,7 @@ export default {
23
24
  meta: {
24
25
  type: 'layout',
25
26
  docs: {
26
- description: 'Enforce an order for manifest keys',
27
+ description: 'Enforces a preferred order for manifest keys',
27
28
  category: 'Stylistic Issues',
28
29
  recommended: false,
29
30
  manifest: true
@@ -40,7 +41,7 @@ export default {
40
41
  additionalProperties: false
41
42
  }],
42
43
  defaultOptions: [{ order: [] }],
43
- messages: { wrongOrder: '`{{first}}` should appear before `{{second}}`' }
44
+ messages: { wrongOrder: '`{{first}}` should appear before `{{second}}`.' }
44
45
  },
45
46
 
46
47
  create(context) {
@@ -0,0 +1,160 @@
1
+ import { getFilename } from '../utils.js'
2
+
3
+ const re = { manifestFilename: /manifest\.json$/, leadingWhitespace: /^[ \t]+/ },
4
+ brackets = { open: new Set(['LBrace', 'LBracket']), close: new Set(['RBrace', 'RBracket']) }
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'layout',
9
+ docs: {
10
+ description: 'Enforces consistent indentation and spacing in `manifest.json`',
11
+ category: 'Stylistic Issues',
12
+ recommended: false,
13
+ manifest: true
14
+ },
15
+ fixable: 'whitespace',
16
+ schema: [{
17
+ type: 'object',
18
+ properties: {
19
+ spaces: {
20
+ type: 'number',
21
+ description: 'Number of spaces per indent level (default 2)'
22
+ },
23
+ checkColons: {
24
+ type: 'boolean',
25
+ description: 'Enforce no spaces before and 1 space after `:` in key/value pairs (default true)'
26
+ },
27
+ checkCommas: {
28
+ type: 'boolean',
29
+ description: 'Enforce no spaces before and 1 space after `,` in inline objects (default true)'
30
+ },
31
+ checkBraces: {
32
+ type: 'boolean',
33
+ description: 'Enforce 1 space after `{` and before `}` in inline objects (default true)'
34
+ },
35
+ checkBrackets: {
36
+ type: 'boolean',
37
+ description: 'Enforce no spaces after `[` or before `]` in inline arrays (default true)'
38
+ }
39
+ },
40
+ additionalProperties: false
41
+ }],
42
+ defaultOptions: [{ spaces: 2, checkColons: true, checkCommas: true,
43
+ checkBraces: true, checkBrackets: true }],
44
+ messages: {
45
+ wrongIndent: 'Expected {{expected}} spaces of indentation (found {{found}}).',
46
+ tabFound: 'Expected spaces for indentation, found tabs.',
47
+ spaceBeforeColon: 'Expected no spaces before `:`.',
48
+ spaceAfterColon: 'Expected 1 space after `:`.',
49
+ spaceBeforeComma: 'Expected no spaces before `,`.',
50
+ spaceAfterComma: 'Expected 1 space after `,`.',
51
+ spaceAfterBrace: 'Expected 1 space after `{`.',
52
+ spaceBeforeBrace: 'Expected 1 space before `}`.',
53
+ spaceInEmptyBraces: 'Expected no spaces in empty `{}`.',
54
+ spaceAfterBracket: 'Expected no spaces after `[`.',
55
+ spaceBeforeBracket: 'Expected no spaces before `]`.'
56
+ }
57
+ },
58
+
59
+ create(context) {
60
+ if (!re.manifestFilename.test(getFilename(context))) return {}
61
+ const opts = context.options[0] || {},
62
+ spaces = opts.spaces ?? 2,
63
+ checkColons = opts.checkColons ?? true,
64
+ checkCommas = opts.checkCommas ?? true,
65
+ checkBraces = opts.checkBraces ?? true,
66
+ checkBrackets = opts.checkBrackets ?? true
67
+ return {
68
+ 'Document'(document) {
69
+ const sourceCode = context.sourceCode,
70
+ text = sourceCode.getText(),
71
+ lines = text.split('\n'),
72
+ tokens = document.tokens || [],
73
+ lineOffsets = [0]
74
+ for (const line of lines)
75
+ lineOffsets.push(lineOffsets.at(-1) + line.length +1)
76
+ let depth = 0, lastLine = 0
77
+ for (const token of tokens) { // check indentation
78
+ const startLine = token.loc.start.line
79
+ if (startLine != lastLine) {
80
+ const indent = lines[startLine -1].match(re.leadingWhitespace)?.[0] || '',
81
+ effectiveDepth = brackets.close.has(token.type) ? Math.max(0, depth -1) : depth,
82
+ expected = ' '.repeat(effectiveDepth * spaces)
83
+ if (indent != expected) {
84
+ const lineStart = lineOffsets[startLine -1],
85
+ isTab = indent.includes('\t')
86
+ context.report({
87
+ loc: { line: startLine, column: 1 }, messageId: isTab ? 'tabFound' : 'wrongIndent',
88
+ data: { expected: effectiveDepth * spaces, found: indent.length },
89
+ fix(fixer) {
90
+ return fixer.replaceTextRange([lineStart, lineStart + indent.length], expected) }
91
+ })
92
+ }
93
+ lastLine = startLine
94
+ }
95
+ if (brackets.open.has(token.type)) depth += 1
96
+ else if (brackets.close.has(token.type)) depth -= 1
97
+ }
98
+ for (let t = 0 ; t < tokens.length ; t++) { // check inline spacing
99
+ const token = tokens[t], // eslint-disable-line security/detect-object-injection
100
+ type = token.type,
101
+ prev = tokens[t -1], next = tokens[t +1]
102
+ if (next) {
103
+ const between = text.slice(token.range[1], next.range[0]),
104
+ inline = !between.includes('\n'),
105
+ isColon = type == 'Colon', isComma = type == 'Comma'
106
+ if (checkColons && isColon && inline && between != ' ')
107
+ context.report({
108
+ loc: token.loc.end, messageId: 'spaceAfterColon',
109
+ fix(fixer) { return fixer.replaceTextRange([token.range[1], next.range[0]], ' ') }
110
+ })
111
+ if (checkCommas && isComma && inline && between != ' ')
112
+ context.report({
113
+ loc: token.loc.end, messageId: 'spaceAfterComma',
114
+ fix(fixer) { return fixer.replaceTextRange([token.range[1], next.range[0]], ' ') }
115
+ })
116
+ if (checkBraces && type == 'LBrace' && inline) {
117
+ const empty = next.type == 'RBrace', want = empty ? '' : ' '
118
+ if (between != want)
119
+ context.report({
120
+ loc: token.loc.end,
121
+ messageId: empty ? 'spaceInEmptyBraces' : 'spaceAfterBrace',
122
+ fix(fixer) { return fixer.replaceTextRange([token.range[1], next.range[0]], want) }
123
+ })
124
+ }
125
+ if (checkBrackets && type == 'LBracket' && inline && between != '')
126
+ context.report({
127
+ loc: token.loc.end, messageId: 'spaceAfterBracket',
128
+ fix(fixer) { return fixer.replaceTextRange([token.range[1], next.range[0]], '') }
129
+ })
130
+ }
131
+ if (prev) {
132
+ const between = text.slice(prev.range[1], token.range[0]),
133
+ inline = !between.includes('\n'),
134
+ isColon = type == 'Colon', isComma = type == 'Comma'
135
+ if (checkColons && isColon && inline && between != '')
136
+ context.report({
137
+ loc: token.loc.start, messageId: 'spaceBeforeColon',
138
+ fix(fixer) { return fixer.replaceTextRange([prev.range[1], token.range[0]], '') }
139
+ })
140
+ if (checkCommas && isComma && inline && between != '')
141
+ context.report({
142
+ loc: token.loc.start, messageId: 'spaceBeforeComma',
143
+ fix(fixer) { return fixer.replaceTextRange([prev.range[1], token.range[0]], '') }
144
+ })
145
+ if (checkBraces && type == 'RBrace' && prev.type != 'LBrace' && inline && between != ' ')
146
+ context.report({
147
+ loc: token.loc.start, messageId: 'spaceBeforeBrace',
148
+ fix(fixer) { return fixer.replaceTextRange([prev.range[1], token.range[0]], ' ') }
149
+ })
150
+ if (checkBrackets && type == 'RBracket' && prev.type != 'LBracket' && inline && between != '')
151
+ context.report({
152
+ loc: token.loc.start, messageId: 'spaceBeforeBracket',
153
+ fix(fixer) { return fixer.replaceTextRange([prev.range[1], token.range[0]], '') }
154
+ })
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
@@ -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,5 @@
1
- import { isValidPermission } from '../data/permissions.js'
2
1
  import { getFilename, readManifest } from '../utils.js'
2
+ import { isValidPermission } from '../data/permissions.js'
3
3
 
4
4
  const re = { manifestFilename: /manifest\.json$/ }
5
5
 
@@ -9,7 +9,7 @@ export default {
9
9
  docs: {
10
10
  description: 'Disallow invalid or unsupported manifest permission names',
11
11
  category: 'Possible Errors',
12
- recommended: true,
12
+ recommended: false,
13
13
  manifest: true
14
14
  },
15
15
  defaultOptions: [{ target: 'mv3', allowedPermissions: [] }],