eslint-plugin-chrome-extensions 2.3.0 → 2.4.1

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.
@@ -2,7 +2,7 @@ import { getMemberPath, isContentScriptFile, readManifest } from '../utils.js'
2
2
 
3
3
  const re = { leadingSlash: /^\//, regexSpecial: /[.+?^${}()|[\]\\]/g }
4
4
 
5
- function collectWarPatterns(manifest) {
5
+ function collectWARpatterns(manifest) {
6
6
  const war = manifest.web_accessible_resources
7
7
  if (!Array.isArray(war)) return []
8
8
  const patterns = []
@@ -29,14 +29,14 @@ export default {
29
29
  },
30
30
  schema: [],
31
31
  messages: {
32
- missingWar: '`{{path}}` is not in `web_accessible_resources`.'
32
+ missingWAR: '`{{path}}` is not in `web_accessible_resources`.'
33
33
  }
34
34
  },
35
35
 
36
36
  create(context) {
37
37
  const manifest = readManifest(context) ; if (!manifest) return {}
38
38
  if (!isContentScriptFile(context, manifest)) return {}
39
- const regexes = collectWarPatterns(manifest).map(patternToRegex)
39
+ const regexes = collectWARpatterns(manifest).map(patternToRegex)
40
40
  return {
41
41
  CallExpression(node) {
42
42
  if (node.callee.type != 'MemberExpression') return
@@ -44,7 +44,7 @@ export default {
44
44
  const arg = node.arguments[0] ; if (arg?.type != 'Literal' || typeof arg.value != 'string') return
45
45
  const resourcePath = arg.value.replace(re.leadingSlash, '')
46
46
  for (const regex of regexes) if (regex.test(resourcePath)) return
47
- context.report({ node: arg, messageId: 'missingWar', data: { path: arg.value }})
47
+ context.report({ node: arg, messageId: 'missingWAR', data: { path: arg.value }})
48
48
  }
49
49
  }
50
50
  }
@@ -48,16 +48,16 @@ export default {
48
48
  patterns = new Set(opts.patterns || remoteCodePatterns),
49
49
  checkClosures = opts.checkExecuteScriptClosures !== false,
50
50
  scriptElems = new Set()
51
- function isLiteralUrl(node) {
52
- return node?.type == 'Literal'
53
- && typeof node.value == 'string'
54
- && re.url.test(node.value)
55
- }
56
51
  function isCreateScript(node) {
57
52
  return node?.type == 'CallExpression'
58
53
  && node.callee?.property?.name == 'createElement'
59
54
  && node.arguments[0]?.value == 'script'
60
55
  }
56
+ function isLiteralUrl(node) {
57
+ return node?.type == 'Literal'
58
+ && typeof node.value == 'string'
59
+ && re.url.test(node.value)
60
+ }
61
61
  function isObjectAssign(node) {
62
62
  return node?.type == 'CallExpression'
63
63
  && node.callee?.type == 'MemberExpression'
@@ -25,7 +25,8 @@ export default {
25
25
  additionalProperties: false
26
26
  }],
27
27
  messages: {
28
- unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. Narrow to specific origins or allow explicitly instead.'
28
+ unrestrictedHost: 'Host permission "{{pattern}}" grants access to every site. '
29
+ + 'Narrow to specific origins or allow explicitly instead.'
29
30
  }
30
31
  },
31
32
 
@@ -38,9 +39,9 @@ export default {
38
39
  const keyName = keyNode.value ?? keyNode.name
39
40
  if (!hostKeys.has(keyName)) return
40
41
  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,
42
+ const elems = arrNode.elements || arrNode.items || []
43
+ for (const elem of elems) {
44
+ const valueNode = elem.value || elem,
44
45
  pattern = valueNode?.value
45
46
  if (typeof pattern != 'string'
46
47
  || allowed.has(pattern)
@@ -0,0 +1,54 @@
1
+ import { readManifest } from '../utils.js'
2
+ import { collectUsedKeys, defaultScanExtensions, findExtensionRoot, readDefaultLocaleMessages } from '../data/i18n.js'
3
+
4
+ const re = { manifestFile: /manifest\.json$/ }
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Flag i18n keys in `messages.json` that are never referenced',
11
+ category: 'Stylistic Issues',
12
+ recommended: false,
13
+ manifest: true
14
+ },
15
+ schema: [{
16
+ type: 'object',
17
+ properties: {
18
+ ignoreKeys: {
19
+ type: 'array', items: { type: 'string' },
20
+ description: 'Keys to always treat as used (e.g. `["myKey"]`)'
21
+ },
22
+ scanExtensions: {
23
+ type: 'array', items: { type: 'string' },
24
+ description: 'File extensions to scan for references'
25
+ }
26
+ },
27
+ additionalProperties: false
28
+ }],
29
+ defaultOptions: [{ ignoreKeys: [], scanExtensions: defaultScanExtensions }],
30
+ messages: {
31
+ unusedKey: '`{{key}}` in `_locales/{{locale}}/messages.json` is never referenced.'
32
+ }
33
+ },
34
+
35
+ create(context) {
36
+ const filename = context.filename
37
+ if (!filename || !re.manifestFile.test(filename)) return {}
38
+ const manifest = readManifest(context) ; if (!manifest?.default_locale) return {}
39
+ const root = findExtensionRoot(filename) ; if (!root) return {}
40
+ const i18n = readDefaultLocaleMessages(manifest, root) ; if (!i18n) return {}
41
+ const opts = context.options[0] || {},
42
+ ignored = new Set(opts.ignoreKeys || []),
43
+ scanExtensions = opts.scanExtensions || defaultScanExtensions
44
+ function check() {
45
+ const { used, patterns } = collectUsedKeys(root, scanExtensions)
46
+ for (const key of Object.keys(i18n.messages)) {
47
+ if (ignored.has(key) || used.has(key) || patterns.some(patt => patt.test(key))) continue
48
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'unusedKey',
49
+ data: { key, locale: i18n.locale }})
50
+ }
51
+ }
52
+ return { 'Document:exit': check, 'Program:exit': check }
53
+ }
54
+ }
@@ -1,8 +1,7 @@
1
- import { apiPermissionMap, findPermission } from '../data/apis.js'
2
-
3
1
  import fs from 'node:fs'
4
2
  import path from 'node:path'
5
3
 
4
+ import { apiPermissionMap, findPermission } from '../data/apis.js'
6
5
  import { findExtensionRoot, getFilename, readManifest } from '../utils.js'
7
6
 
8
7
  const re = { manifestFilename: /manifest\.json$/ }
@@ -21,8 +20,8 @@ function collectUsedAPIs(manifest, root) {
21
20
  const used = new Set()
22
21
  for (const file of files) {
23
22
  try {
24
- // eslint-disable-next-line security/detect-non-literal-fs-filename
25
- const src = fs.readFileSync(path.resolve(root, file), 'utf8')
23
+ const src = // eslint-disable-next-line security/detect-non-literal-fs-filename
24
+ fs.readFileSync(path.resolve(root, file), 'utf8')
26
25
  for (const match of src.matchAll(re.chromeAPI)) used.add(match[0])
27
26
  } catch {} // eslint-disable-line no-empty
28
27
  }
@@ -65,9 +64,9 @@ export default {
65
64
  const keyName = keyNode.value ?? keyNode.name
66
65
  if (keyName != 'permissions' && keyName != 'optional_permissions') return
67
66
  const arrNode = node.value ; if (arrNode?.type != 'Array') return
68
- const elements = arrNode.elements || arrNode.items || []
69
- for (const el of elements) {
70
- const valueNode = el.value || el
67
+ const elems = arrNode.elements || arrNode.items || []
68
+ for (const elem of elems) {
69
+ const valueNode = elem.value || elem
71
70
  const perm = valueNode?.value
72
71
  if (typeof perm != 'string') continue
73
72
  if (!permissionNodes.has(perm)) permissionNodes.set(perm, [])
@@ -0,0 +1,27 @@
1
+ import path from 'node:path'
2
+
3
+ import { findLicenseFile } from '../data/license.js'
4
+
5
+ export default {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: {
9
+ description: 'Requires a license file in the extension',
10
+ recommended: false,
11
+ manifest: true
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ missingFile: 'No license file found. Add one of: LICENSE, LICENSE.md, LICENSE.txt, COPYING '
16
+ + '(at extension root or in a subdir).'
17
+ }
18
+ },
19
+ create(context) {
20
+ const { filename } = context,
21
+ check = node => {
22
+ if (!findLicenseFile(path.dirname(filename)))
23
+ context.report({ node, messageId: 'missingFile' })
24
+ }
25
+ return { Program: check, Document: check }
26
+ }
27
+ }
@@ -0,0 +1,71 @@
1
+ import { getFilename } from '../utils.js'
2
+
3
+ const defaults = { maxNameLength: 12 },
4
+ re = { i18nPlaceholder: /^__MSG_.+__$/, manifestFilename: /manifest\.json$/ }
5
+
6
+ function isI18n(value) { return typeof value == 'string' && re.i18nPlaceholder.test(value) }
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'suggestion',
11
+ docs: {
12
+ description: 'Requires a `short_name` when `name` exceeds 12 chars',
13
+ category: 'Stylistic Issues',
14
+ recommended: false,
15
+ manifest: true
16
+ },
17
+ schema: [{
18
+ type: 'object',
19
+ properties: {
20
+ maxNameLength: {
21
+ type: 'number',
22
+ description: 'Length of `name` above which `short_name` is required (default `12`)'
23
+ },
24
+ always: {
25
+ type: 'boolean',
26
+ description: 'Require `short_name` regardless of `name` length (default `false`)'
27
+ },
28
+ checkRedundant: {
29
+ type: 'boolean',
30
+ description: 'Also flag `short_name` that duplicates `name` (default `false`)'
31
+ }
32
+ },
33
+ additionalProperties: false
34
+ }],
35
+ defaultOptions: [{
36
+ maxNameLength: defaults.maxNameLength, always: false, checkRedundant: false
37
+ }],
38
+ messages: {
39
+ missingShortName: '`name` exceeds {{max}} chars. Declare `short_name` to control truncation.',
40
+ shortNameTooLong: '`short_name` exceeds the recommended {{max}}-character limit (found {{length}}).',
41
+ redundantShortName: '`short_name` duplicates `name` (Chrome falls back to `name` automatically).'
42
+ }
43
+ },
44
+
45
+ create(context) {
46
+ if (!re.manifestFilename.test(getFilename(context))) return {}
47
+ const opts = context.options[0] || {},
48
+ maxNameLength = opts.maxNameLength ?? defaults.maxNameLength,
49
+ always = opts.always ?? false,
50
+ checkRedundant = opts.checkRedundant ?? false
51
+ let manifest
52
+ try { manifest = JSON.parse(context.sourceCode.text) } catch { return {} }
53
+ if (!manifest || typeof manifest != 'object') return {}
54
+ const name = manifest.name, shortName = manifest.short_name
55
+ if (typeof name != 'string') return {}
56
+ const nameIsI18n = isI18n(name),
57
+ shortNameIsI18n = isI18n(shortName),
58
+ loc = { line: 1, column: 1 }
59
+ if (typeof shortName != 'string') {
60
+ if (always || (!nameIsI18n && name.length > maxNameLength))
61
+ context.report({ loc, messageId: 'missingShortName', data: { max: maxNameLength }})
62
+ } else {
63
+ if (!shortNameIsI18n && shortName.length > maxNameLength)
64
+ context.report({ loc, messageId: 'shortNameTooLong',
65
+ data: { max: maxNameLength, length: shortName.length }})
66
+ if (checkRedundant && shortName == name)
67
+ context.report({ loc, messageId: 'redundantShortName' })
68
+ }
69
+ return {}
70
+ }
71
+ }
@@ -0,0 +1,152 @@
1
+ /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ const re = { invalidChars: /[^\w\-.]/, manifestFilename: /manifest\.json$/, reservedPrefix: /^_/ }
7
+
8
+ const refKeys = [
9
+ ['action.default_popup', manifest => manifest.action?.default_popup],
10
+ ['background.page', manifest => manifest.background?.page],
11
+ ['background.service_worker', manifest => manifest.background?.service_worker],
12
+ ['devtools_page', manifest => manifest.devtools_page],
13
+ ['options_page', manifest => manifest.options_page],
14
+ ['options_ui.page', manifest => manifest.options_ui?.page],
15
+ ['side_panel.default_path', manifest => manifest.side_panel?.default_path]
16
+ ]
17
+
18
+ function findExtensionRoot(startPath) {
19
+ let dir = path.dirname(startPath)
20
+ while (dir != path.dirname(dir)) {
21
+ if (fs.existsSync(path.join(dir, 'manifest.json'))) return dir
22
+ dir = path.dirname(dir)
23
+ }
24
+ }
25
+
26
+ function collectRefs(manifest) {
27
+ const refs = []
28
+ for (const [label, get] of refKeys) {
29
+ const val = get(manifest)
30
+ if (typeof val == 'string') refs.push({ label, file: val })
31
+ }
32
+ if (manifest.background?.scripts)
33
+ refs.push(...manifest.background.scripts.map(file => ({ label: 'background.scripts', file })))
34
+ if (manifest.icons)
35
+ refs.push(...Object.values(manifest.icons).map(file => ({ label: 'icons', file })))
36
+ if (manifest.action?.default_icon && typeof manifest.action.default_icon == 'object')
37
+ refs.push(...Object.values(manifest.action.default_icon).map(file => ({ label: 'action.default_icon', file })))
38
+ for (const cs of manifest.content_scripts || []) {
39
+ if (cs.js) refs.push(...cs.js.map(file => ({ label: 'content_scripts.js', file })))
40
+ if (cs.css) refs.push(...cs.css.map(file => ({ label: 'content_scripts.css', file })))
41
+ }
42
+ for (const war of manifest.web_accessible_resources || [])
43
+ if (war.resources) refs.push(...war.resources.map(file => ({ label: 'web_accessible_resources', file })))
44
+ return refs
45
+ }
46
+
47
+ function resolveRef(abs) {
48
+ const dir = path.dirname(abs),
49
+ base = path.basename(abs)
50
+ let entries
51
+ try { entries = fs.readdirSync(dir) } catch { return { id: 'fileMissing' }}
52
+ if (entries.includes(base)) return null
53
+ const hit = entries.find(entry => entry.toLowerCase() == base.toLowerCase())
54
+ return hit ? { id: 'caseMismatch', actual: hit } : { id: 'fileMissing' }
55
+ }
56
+
57
+ export default {
58
+ meta: {
59
+ type: 'problem',
60
+ docs: {
61
+ description: 'Enforce valid filenames and paths in Chrome extensions',
62
+ category: 'Possible Errors',
63
+ recommended: true,
64
+ manifest: true,
65
+ css: true,
66
+ html: true,
67
+ json: true
68
+ },
69
+ schema: [{
70
+ type: 'object',
71
+ properties: {
72
+ noReservedPrefix: {
73
+ type: 'boolean',
74
+ description: 'Disallow filenames/dirs starting w/ `_` (except `_locales`)'
75
+ },
76
+ noSpecialChars: {
77
+ type: 'boolean',
78
+ description: 'Warn on characters outside `a-z`, `0-9`, `-`, `_`, `.`'
79
+ },
80
+ checkFileExists: {
81
+ type: 'boolean',
82
+ description: 'Verify files referenced in `manifest.json` exist on disk'
83
+ },
84
+ enforceCaseSensitivity: {
85
+ type: 'boolean',
86
+ description: 'Verify manifest references match actual file case'
87
+ },
88
+ maxFullPathLength: {
89
+ type: 'number',
90
+ description: 'Max full path length (default 260)'
91
+ }
92
+ },
93
+ additionalProperties: false
94
+ }],
95
+ defaultOptions: [{
96
+ noReservedPrefix: true, noSpecialChars: true, checkFileExists: true, enforceCaseSensitivity: true,
97
+ maxFullPathLength: 260
98
+ }],
99
+ messages: {
100
+ reservedPrefix: '`{{name}}` starts with reserved prefix `_` (only `_locales` is allowed).',
101
+ specialChars: '`{{name}}` contains characters outside `a-z`, `0-9`, `-`, `_`, and `.`.',
102
+ pathTooLong: 'Full path length ({{length}}) exceeds the {{max}}-character limit (Windows `MAX_PATH`).',
103
+ fileMissing: '`{{label}}` references `{{file}}`, which does not exist.',
104
+ caseMismatch: '`{{label}}` references `{{file}}`, but the actual file is `{{actual}}`.'
105
+ }
106
+ },
107
+
108
+ create(context) {
109
+ const opts = context.options[0] || {},
110
+ noReservedPrefix = opts.noReservedPrefix ?? true,
111
+ noSpecialChars = opts.noSpecialChars ?? true,
112
+ checkFileExists = opts.checkFileExists ?? true,
113
+ enforceCaseSensitivity = opts.enforceCaseSensitivity ?? true,
114
+ maxFullPathLength = opts.maxFullPathLength ?? 260,
115
+ filename = context.filename
116
+ if (!filename || filename == '<input>') return {}
117
+ const basename = path.basename(filename),
118
+ extRoot = findExtensionRoot(filename) || path.dirname(filename),
119
+ segments = path.relative(extRoot, filename).split(path.sep)
120
+ if (noReservedPrefix)
121
+ for (const seg of segments)
122
+ if (re.reservedPrefix.test(seg) && seg != '_locales')
123
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'reservedPrefix', data: { name: seg }})
124
+ if (noSpecialChars)
125
+ for (const seg of segments)
126
+ if (re.invalidChars.test(seg))
127
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'specialChars', data: { name: seg }})
128
+ if (filename.length >= maxFullPathLength)
129
+ context.report({
130
+ loc: { line: 1, column: 1 }, messageId: 'pathTooLong',
131
+ data: { length: filename.length, max: maxFullPathLength }
132
+ })
133
+ if (basename != 'manifest.json' || (!checkFileExists && !enforceCaseSensitivity)) return {}
134
+ const manifestPath = path.join(extRoot, 'manifest.json')
135
+ if (!fs.existsSync(manifestPath)) return {}
136
+ let manifest ; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) } catch { return {} }
137
+ for (const { label, file } of collectRefs(manifest)) {
138
+ if (file.includes('*')) continue // wildcard refs can't be verified
139
+ const abs = path.resolve(extRoot, file),
140
+ result = resolveRef(abs)
141
+ if (!result
142
+ || result.id == 'fileMissing' && !checkFileExists
143
+ || result.id == 'caseMismatch' && !enforceCaseSensitivity
144
+ ) continue
145
+ const data = result.id == 'caseMismatch'
146
+ ? { label, file, actual: result.actual }
147
+ : { label, file }
148
+ context.report({ loc: { line: 1, column: 1 }, messageId: result.id, data })
149
+ }
150
+ return {}
151
+ }
152
+ }
@@ -0,0 +1,59 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
3
+
4
+ import { re } from '../data/license.js'
5
+
6
+ const require = createRequire(import.meta.url),
7
+ spdxLicenseIDs = require('spdx-license-ids')
8
+
9
+ function toArray(value) { return value == null ? [] : Array.isArray(value) ? value : [value] }
10
+
11
+ function validateContent(context, node, { requireSPDX, allowedLicenses }) {
12
+ const match = context.sourceCode.text.match(re.spdxTag)
13
+ if (!match) return void (requireSPDX && context.report({ node, messageId: 'missingSPDX' }))
14
+ const id = match[1]
15
+ if (!spdxLicenseIDs.includes(id))
16
+ return context.report({ node, messageId: 'invalidSPDX', data: { id }})
17
+ if (allowedLicenses.length && !allowedLicenses.includes(id))
18
+ context.report({ node, messageId: 'disallowedSPDX', data: { id, allowed: allowedLicenses.join(', ') }})
19
+ }
20
+
21
+ export default {
22
+ meta: {
23
+ type: 'problem',
24
+ docs: {
25
+ description: 'Validates SPDX identifiers in license files',
26
+ recommended: false,
27
+ license: true
28
+ },
29
+ defaultOptions: [{ requireSPDX: false, allowedLicenses: [] }],
30
+ schema: [{
31
+ type: 'object',
32
+ properties: {
33
+ requireSPDX: { type: 'boolean', description: 'Require an SPDX-License-Identifier line' },
34
+ allowedLicenses: {
35
+ description: 'SPDX license IDs to restrict to',
36
+ oneOf: [
37
+ { type: 'string', description: 'Single SPDX license ID' },
38
+ { type: 'array', description: 'Array of SPDX license IDs', items: { type: 'string' }}
39
+ ]
40
+ }
41
+ },
42
+ additionalProperties: false
43
+ }],
44
+ messages: {
45
+ missingSPDX: 'License file missing `SPDX-License-Identifier` line (e.g. `SPDX-License-Identifier: MIT`).',
46
+ invalidSPDX: 'Invalid SPDX identifier `{{id}}` (see https://spdx.org/licenses/).',
47
+ disallowedSPDX: 'License `{{id}}` is not in allowed list: {{allowed}}.'
48
+ }
49
+ },
50
+ create(context) {
51
+ const opts = context.options[0],
52
+ { requireSPDX } = opts,
53
+ allowedLicenses = toArray(opts.allowedLicenses),
54
+ { filename } = context,
55
+ basename = path.basename(filename)
56
+ if (!re.licenseFilename.test(basename)) return {}
57
+ return { Program: node => validateContent(context, node, { requireSPDX, allowedLicenses })}
58
+ }
59
+ }
@@ -64,12 +64,12 @@ export default {
64
64
  data: { directive }})
65
65
  if (inDataCollection && (keyName == 'required' || keyName == 'optional')
66
66
  && Array.isArray(node.value?.elements)
67
- ) for (const el of node.value.elements) {
68
- const value = typeof el.value == 'string' ? el.value : el.value?.value
67
+ ) for (const elem of node.value.elements) {
68
+ const value = typeof elem.value == 'string' ? elem.value : elem.value?.value
69
69
  if (typeof value == 'string') {
70
70
  const set = keyName == 'required' ? ffDataCollectionRequired : ffDataCollectionOptional
71
71
  if (!set.has(value))
72
- pendingReports.push({ node: el, messageId: 'invalidDataCollectionValue',
72
+ pendingReports.push({ node: elem, messageId: 'invalidDataCollectionValue',
73
73
  data: { value, kind: keyName }})
74
74
  }
75
75
  }
@@ -11,7 +11,8 @@ export default {
11
11
  },
12
12
  schema: [],
13
13
  messages: {
14
- listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level so it attaches on service worker wake-up.',
14
+ listenerNotTopLevel: '`chrome.*.addListener()` must be registered at module top level'
15
+ + ' so it attaches on service worker wake-up.',
15
16
  keepaliveTimer: 'Sub-30s `{{fn}}` will not keep the MV3 service worker alive. Use `chrome.alarms` instead.',
16
17
  domInSW: '`{{global}}` is not available in a service worker.'
17
18
  }
@@ -34,9 +35,10 @@ export default {
34
35
  ) context.report({ node, messageId: 'keepaliveTimer', data: { fn: node.callee.name }})
35
36
  },
36
37
  Identifier(node) {
37
- if (node.parent?.type == 'MemberExpression' && node.parent.property === node) return
38
- if (node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed) return
39
- if (!domGlobals.has(node.name)) return
38
+ if (node.parent?.type == 'MemberExpression' && node.parent.property === node
39
+ || node.parent?.type == 'Property' && node.parent.key === node && !node.parent.computed
40
+ || !domGlobals.has(node.name)
41
+ ) return
40
42
  for (let scope = context.sourceCode.getScope(node) ;
41
43
  scope && scope.type != 'global' ;
42
44
  scope = scope.upper
@@ -0,0 +1,120 @@
1
+ import { matchPaths, urlPaths } from '../data/manifest.js'
2
+ import { getFilename } from '../utils.js'
3
+
4
+ const re = {
5
+ absoluteURL: /^[a-z][a-z0-9+.-]*:\/\/[^\s/]+/i, manifestFilename: /manifest\.json$/,
6
+ match: /^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/, template: /\{[^}]+\}/g
7
+ }
8
+
9
+ function collectStrings(obj, path = [], out = []) {
10
+ if (obj == null) return out
11
+ if (typeof obj == 'string') out.push({ path: path.join('.'), value: obj })
12
+ else if (Array.isArray(obj))
13
+ for (const elem of obj) collectStrings(elem, path, out)
14
+ else if (typeof obj == 'object')
15
+ for (const key of Object.keys(obj)) // eslint-disable-next-line security/detect-object-injection
16
+ collectStrings(obj[key], [...path, key], out)
17
+ return out
18
+ }
19
+
20
+ function isURL(value) { return re.absoluteURL.test(value.replace(re.template, 'x')) }
21
+
22
+ export default {
23
+ meta: {
24
+ type: 'problem',
25
+ docs: {
26
+ description: 'Validates URLs and match patterns in manifest fields',
27
+ category: 'Possible Errors',
28
+ recommended: true,
29
+ manifest: true
30
+ },
31
+ schema: [{
32
+ type: 'object',
33
+ properties: {
34
+ checkURLfields: {
35
+ type: 'boolean',
36
+ description: 'Validate URL fields (`homepage_url`, `update_url`, etc.)'
37
+ },
38
+ checkMatchPatterns: {
39
+ type: 'boolean',
40
+ description: 'Validate match patterns (`host_permissions`, `content_scripts`, etc.)'
41
+ },
42
+ allowedSchemes: {
43
+ type: 'array', items: { type: 'string' },
44
+ description: 'Permitted URL schemes for match patterns (default `["https", "file"]`)'
45
+ },
46
+ allowAllURLs: {
47
+ type: 'boolean',
48
+ description: 'Allow unrestricted patterns (`<all_urls>`, `https://*/*`, `*://*/*`)'
49
+ }
50
+ },
51
+ additionalProperties: false
52
+ }],
53
+ defaultOptions: [{
54
+ checkURLfields: true, checkMatchPatterns: true,
55
+ allowedSchemes: ['https', 'file'], allowAllURLs: false
56
+ }],
57
+ messages: {
58
+ invalidURL: '`{{field}}` has an invalid URL: "{{value}}".',
59
+ invalidMatchPattern: '`{{field}}` has an invalid match pattern: "{{value}}".',
60
+ invalidScheme: '`{{field}}` uses a disallowed scheme: "{{value}}".',
61
+ invalidFileHost: '`{{field}}` uses a `file://` scheme w/ a non-empty host: "{{value}}".',
62
+ invalidHost: '`{{field}}` has an invalid wildcard host: "{{value}}". Use `*.` prefix instead.',
63
+ invalidWARPath: '`{{field}}` must use an effective path of `/*`: "{{value}}".',
64
+ invalidExternalHost: '`{{field}}` does not allow wildcards in the host: "{{value}}".',
65
+ allURLs: '`{{field}}` uses the unrestricted `<all_urls>` pattern.'
66
+ }
67
+ },
68
+
69
+ create(context) {
70
+ if (!re.manifestFilename.test(getFilename(context))) return {}
71
+ const opts = context.options[0] || {},
72
+ checkURLfields = opts.checkURLfields ?? true,
73
+ checkMatchPatterns = opts.checkMatchPatterns ?? true,
74
+ allowedSchemes = opts.allowedSchemes ?? ['https', 'file'],
75
+ allowAllURLs = opts.allowAllURLs ?? false
76
+ if (!checkURLfields && !checkMatchPatterns) return {}
77
+ return {
78
+ 'Document:exit'() {
79
+ let manifest
80
+ try { manifest = JSON.parse(context.sourceCode.text) } catch { return }
81
+ for (const { path, value } of collectStrings(manifest)) {
82
+ if (checkURLfields && urlPaths.has(path)) {
83
+ if (!isURL(value))
84
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidURL',
85
+ data: { field: path, value }})
86
+ } else if (checkMatchPatterns && matchPaths.has(path)) {
87
+ if (value == '<all_urls>') {
88
+ if (!allowAllURLs)
89
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'allURLs',
90
+ data: { field: path }})
91
+ continue
92
+ }
93
+ const match = value.match(re.match)
94
+ if (!match) {
95
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidMatchPattern',
96
+ data: { field: path, value }})
97
+ continue
98
+ }
99
+ const [, scheme, host] = match
100
+ if (scheme != '*' && !allowedSchemes.includes(scheme))
101
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidScheme',
102
+ data: { field: path, value }})
103
+ else if (scheme == 'file' && host != '')
104
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidFileHost',
105
+ data: { field: path, value }})
106
+ else if (path == 'externally_connectable.matches' && host.includes('*'))
107
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidExternalHost',
108
+ data: { field: path, value }})
109
+ else if (host.includes('*') && host != '*' && !host.startsWith('*.'))
110
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidHost',
111
+ data: { field: path, value }})
112
+ else if (path == 'web_accessible_resources.matches' && !value.endsWith('/*'))
113
+ context.report({ loc: { line: 1, column: 1 }, messageId: 'invalidWARPath',
114
+ data: { field: path, value }})
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }