eslint-plugin-chrome-extensions 2.6.0 → 2.8.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.
Files changed (50) hide show
  1. package/README.md +648 -33
  2. package/package.json +11 -9
  3. package/src/data/apis.js +74 -88
  4. package/src/data/manifest.js +3 -1
  5. package/src/data/regex.js +3 -1
  6. package/src/index.d.ts +42 -0
  7. package/src/index.js +0 -4
  8. package/src/rules/css-spacing.js +375 -0
  9. package/src/rules/manifest-key-order.js +1 -2
  10. package/src/rules/manifest-spacing.js +1 -2
  11. package/src/rules/no-content-style-leak.js +12 -11
  12. package/src/rules/no-deprecated-apis.js +2 -2
  13. package/src/rules/no-duplicate-manifest-keys.js +0 -1
  14. package/src/rules/no-global-keyframes.js +84 -0
  15. package/src/rules/no-host-page-selectors.js +105 -0
  16. package/src/rules/no-inline-event-handlers.js +116 -0
  17. package/src/rules/no-inline-scripts.js +65 -0
  18. package/src/rules/no-insecure-fetch.js +7 -6
  19. package/src/rules/no-insecure-messaging.js +4 -4
  20. package/src/rules/no-invalid-locales.js +3 -5
  21. package/src/rules/no-missing-default-locale.js +1 -1
  22. package/src/rules/no-missing-permissions.js +2 -3
  23. package/src/rules/no-missing-web-accessible-resources.js +2 -2
  24. package/src/rules/no-remote-code.js +77 -28
  25. package/src/rules/no-remote-objects.js +55 -0
  26. package/src/rules/no-remote-urls.js +102 -0
  27. package/src/rules/no-scrollbar-styling.js +71 -0
  28. package/src/rules/no-unrestricted-host-permissions.js +1 -1
  29. package/src/rules/no-unused-i18n-keys.js +0 -1
  30. package/src/rules/no-unused-permissions.js +5 -4
  31. package/src/rules/require-license-file.js +1 -2
  32. package/src/rules/require-mv3.js +3 -2
  33. package/src/rules/require-short-name-when-useful.js +0 -1
  34. package/src/rules/valid-filenames.js +3 -3
  35. package/src/rules/valid-license-file.js +2 -4
  36. package/src/rules/valid-manifest-icons.js +2 -2
  37. package/src/rules/valid-mv3-schema.js +5 -5
  38. package/src/rules/valid-url-fields.js +2 -2
  39. package/src/rules/valid-version-field.js +2 -2
  40. package/src/utils/api.js +20 -0
  41. package/src/utils/array.js +1 -0
  42. package/src/utils/extension.js +1 -3
  43. package/src/utils/html.js +29 -0
  44. package/src/utils/i18n.js +2 -2
  45. package/src/utils/init.js +21 -9
  46. package/src/utils/license.js +1 -1
  47. package/src/utils/node.js +3 -1
  48. package/src/utils/pkg.js +19 -2
  49. package/src/utils/rules.js +1 -1
  50. package/src/data/deprecations.js +0 -8
@@ -0,0 +1,116 @@
1
+ /* oxlint-disable security/detect-object-injection */
2
+
3
+ import array from '../utils/array.js'
4
+ import html from '../utils/html.js'
5
+
6
+ const re = {
7
+ attrNameEnd: /[\s=>]/,
8
+ block: {
9
+ script: /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)/gi,
10
+ style: /(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi
11
+ },
12
+ htmlComment: /<!--[\s\S]*?-->/g,
13
+ inlineHandler: /^on[a-z]+$/i,
14
+ quote: /["']/,
15
+ tagOpen: /<[a-z][\w-]*/gi,
16
+ whitespace: /\s/
17
+ }
18
+
19
+ function isQuote(char) { return re.quote.test(char) }
20
+ function maskInner(_, open, inner, close) { return open + spaces(inner.length) + close }
21
+ function spaces(n) { return ' '.repeat(n) }
22
+
23
+ function parseAttrs(attrPart) {
24
+ const attrs = [] ; let i = 0
25
+ while (i < attrPart.length) {
26
+ while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
27
+ if (i >= attrPart.length) break
28
+ const nameStart = i
29
+ while (i < attrPart.length && !re.attrNameEnd.test(attrPart[i])) i++
30
+ const name = attrPart.slice(nameStart, i)
31
+ if (!name) { i++ ; continue }
32
+ while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
33
+ if (attrPart[i] == '=') {
34
+ i++
35
+ while (i < attrPart.length && re.whitespace.test(attrPart[i])) i++
36
+ if (isQuote(attrPart[i])) {
37
+ const q = attrPart[i]
38
+ i++
39
+ while (i < attrPart.length && attrPart[i] !== q) i++
40
+ i++
41
+ } else
42
+ while (i < attrPart.length && !re.whitespace.test(attrPart[i]) && attrPart[i] != '>') i++
43
+ }
44
+ attrs.push({ name, offset: nameStart })
45
+ }
46
+ return attrs
47
+ }
48
+
49
+ export default {
50
+ meta: {
51
+ type: 'problem',
52
+ docs: {
53
+ description: 'Disallow inline event handlers (e.g. `onclick`, `onload`) in HTML '
54
+ + '(blocked by MV3\'s CSP at runtime)',
55
+ category: 'Possible Errors',
56
+ html: true
57
+ },
58
+ defaultOptions: [{ allowedAttributes: [] }],
59
+ schema: [{
60
+ type: 'object',
61
+ properties: {
62
+ allowedAttributes: {
63
+ oneOf: [
64
+ { type: 'string', description: 'A single attribute name' },
65
+ { type: 'array', items: { type: 'string' }, description: 'List of attribute names' }
66
+ ],
67
+ description: 'Inline event handler attributes to permit'
68
+ }
69
+ },
70
+ additionalProperties: false
71
+ }],
72
+ messages: {
73
+ inlineHandler: 'Inline event handler `{{attr}}` is blocked by MV3\'s `script-src \'self\'`. '
74
+ + 'Move the handler to JS with `addEventListener()`.'
75
+ }
76
+ },
77
+
78
+ create(context) {
79
+ const opts = context.options[0] || {},
80
+ allowedAttributes = new Set(array.to(opts.allowedAttributes ?? []).map(a => a.toLowerCase())),
81
+ text = context.sourceCode.text
82
+ let scanned = false
83
+ function maskContent() {
84
+ return text
85
+ .replace(re.htmlComment, match => spaces(match.length))
86
+ .replace(re.block.script, maskInner)
87
+ .replace(re.block.style, maskInner)
88
+ }
89
+ function scan() {
90
+ if (scanned) return
91
+ scanned = true
92
+ if (!html.isHTML(text)) return
93
+ const masked = maskContent()
94
+ for (const tagMatch of masked.matchAll(re.tagOpen)) {
95
+ const tagEnd = tagMatch.index + tagMatch[0].length
96
+ let i = tagEnd, quote = null
97
+ while (i < masked.length) {
98
+ const char = masked[i]
99
+ if (quote) { if (char === quote) quote = null }
100
+ else if (isQuote(char)) quote = char
101
+ else if (char == '>') break
102
+ i++
103
+ }
104
+ const attrPart = masked.slice(tagEnd, i)
105
+ for (const attr of parseAttrs(attrPart)) {
106
+ if (!re.inlineHandler.test(attr.name)) continue
107
+ const attrLower = attr.name.toLowerCase()
108
+ if (allowedAttributes.has(attrLower)) continue
109
+ context.report({ loc: html.offsetToLoc(text, tagEnd + attr.offset),
110
+ messageId: 'inlineHandler', data: { attr: attrLower }})
111
+ }
112
+ }
113
+ }
114
+ return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
115
+ }
116
+ }
@@ -0,0 +1,65 @@
1
+ import array from '../utils/array.js'
2
+ import html from '../utils/html.js'
3
+
4
+ const re = {
5
+ nonExecutable: /^(?:application\/(?:ld\+)?json|importmap|speculationrules|text\/(?:x-)?template)$/i,
6
+ script: /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi
7
+ }
8
+
9
+ export default {
10
+ meta: {
11
+ type: 'problem',
12
+ docs: {
13
+ description: 'Disallow inline `<script>` bodies and remote `<script src>` in extension HTML',
14
+ category: 'Possible Errors',
15
+ html: true
16
+ },
17
+ defaultOptions: [{ allowedSrcPrefixes: ['chrome-extension://'] }],
18
+ schema: [{
19
+ type: 'object',
20
+ properties: {
21
+ allowedSrcPrefixes: {
22
+ oneOf: [
23
+ { type: 'string', description: 'A single allowed src prefix' },
24
+ { type: 'array', items: { type: 'string' }, description: 'List of allowed src prefixes' }
25
+ ],
26
+ description: 'Additional src prefixes to permit'
27
+ }
28
+ },
29
+ additionalProperties: false
30
+ }],
31
+ messages: {
32
+ inlineBody: 'Inline `<script>` bodies are blocked by MV3\'s `script-src \'self\'`. Move the code to an external file.',
33
+ remoteSrc: 'Remote `<script src="{{src}}">` is blocked by MV3\'s `script-src \'self\'`. Bundle the script or use `chrome.runtime.getURL()`.'
34
+ }
35
+ },
36
+
37
+ create(context) {
38
+ const opts = context.options[0] || {},
39
+ allowedPrefixes = array.to(opts.allowedSrcPrefixes ?? ['chrome-extension://']),
40
+ text = context.sourceCode.text
41
+ let scanned = false
42
+ function scan() {
43
+ if (scanned) return
44
+ scanned = true
45
+ if (!html.isHTML(text)) return
46
+ for (const match of text.matchAll(re.script)) {
47
+ const [, attrs, body] = match,
48
+ type = html.pickAttrVal(attrs.match(html.re.attr.type))
49
+ if (type && re.nonExecutable.test(type.trim())) continue
50
+ const src = html.pickAttrVal(attrs.match(html.re.attr.src))
51
+ if (src !== null) {
52
+ const trimmed = src.trim()
53
+ if (trimmed && html.isRemote(trimmed, allowedPrefixes))
54
+ context.report({ loc: html.offsetToLoc(text, match.index),
55
+ messageId: 'remoteSrc', data: { src: trimmed }})
56
+ continue
57
+ }
58
+ if (body && body.trim().length > 0)
59
+ context.report({ loc: html.offsetToLoc(text, match.index),
60
+ messageId: 'inlineBody' })
61
+ }
62
+ }
63
+ return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
64
+ }
65
+ }
@@ -1,5 +1,12 @@
1
1
  const re = { insecureURL: /^http:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])/i }
2
2
 
3
+ function urlFromLiteral(node) {
4
+ return ( node?.type == 'Literal'
5
+ && typeof node.value == 'string'
6
+ && re.insecureURL.test(node.value)
7
+ ) ? node.value : null
8
+ }
9
+
3
10
  export default {
4
11
  meta: {
5
12
  type: 'problem',
@@ -18,12 +25,6 @@ export default {
18
25
 
19
26
  create(context) {
20
27
  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
28
  return {
28
29
  VariableDeclarator(node) {
29
30
  if (node.init?.type == 'NewExpression'
@@ -1,7 +1,8 @@
1
+ import api from '../utils/api.js'
1
2
  import nodeUtils from '../utils/node.js'
2
- import apis from '../data/apis.js'
3
3
 
4
- const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ }
4
+ const re = { postmsgValidation: /\b\w+\.(?:origin|source)\b/, senderValidation: /sender\.(?:id|origin)/ },
5
+ isMessageTarget = obj => obj?.type == 'Identifier' && ['self', 'window'].includes(obj.name)
5
6
 
6
7
  export default {
7
8
  meta: {
@@ -40,13 +41,12 @@ export default {
40
41
  if (re.postmsgValidation.test(body)) return
41
42
  context.report({ node, messageId: 'unvalidatedPostMessage', data: { target }})
42
43
  }
43
- function isMessageTarget(obj) { return obj?.type == 'Identifier' && ['self', 'window'].includes(obj.name) }
44
44
  return {
45
45
  CallExpression(node) {
46
46
  if (node.callee.type != 'MemberExpression') return
47
47
  const path = nodeUtils.getMemberPath(node.callee) ; if (!path) return
48
48
  const listenerRoot = path.endsWith('.addListener') ? path.slice(0, -'.addListener'.length) : null
49
- if (listenerRoot && apis.isExternalMsgListener(listenerRoot)) {
49
+ if (listenerRoot && api.isExternalMsgListener(listenerRoot)) {
50
50
  const handler = node.arguments[0] ; if (!handler) return
51
51
  const body = context.sourceCode.getText(handler)
52
52
  if (!re.senderValidation.test(body))
@@ -1,5 +1,3 @@
1
- /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
2
-
3
1
  import extension from '../utils/extension.js'
4
2
  import fs from 'node:fs'
5
3
  import i18nUtils from '../utils/i18n.js'
@@ -51,11 +49,11 @@ export default {
51
49
  root = extension.findRoot(context)
52
50
  if (root) {
53
51
  const localesDir = path.join(root, '_locales')
54
- try {
52
+ try { // oxlint-disable-next-line security/detect-non-literal-fs-filename
55
53
  for (const entry of fs.readdirSync(localesDir, { withFileTypes: true }))
56
54
  if (entry.isDirectory() && isInvalid(entry.name)) invalidFolders.push(entry.name)
57
- invalidFolders.sort()
58
- } catch {} // eslint-disable-line no-empty
55
+ invalidFolders.sort((a, b) => a < b ? -1 : a > b ? 1 : 0)
56
+ } catch {}
59
57
  }
60
58
  const loc = { line: 1, column: 1 }
61
59
  if (!isManifest) {
@@ -1,4 +1,4 @@
1
- /* eslint-disable security/detect-non-literal-fs-filename -- lint-time project inspection only */
1
+ /* oxlint-disable security/detect-non-literal-fs-filename */
2
2
 
3
3
  import extension from '../utils/extension.js'
4
4
  import fs from 'node:fs'
@@ -1,8 +1,7 @@
1
+ import apiUtils from '../utils/api.js'
1
2
  import extension from '../utils/extension.js'
2
3
  import nodeUtils from '../utils/node.js'
3
4
 
4
- import apis from '../data/apis.js'
5
-
6
5
  function queriesActiveTab(node) {
7
6
  const arg = node.arguments[0] ; if (arg?.type != 'ObjectExpression') return false
8
7
  return arg.properties.some(prop => {
@@ -34,7 +33,7 @@ export default {
34
33
  CallExpression(node) {
35
34
  if (node.callee.type != 'MemberExpression') return
36
35
  const api = nodeUtils.getMemberPath(node.callee) ; if (!api) return
37
- const permission = apis.findPermission(api) ; if (!permission || declared.has(permission)) return
36
+ const permission = apiUtils.findPermission(api) ; if (!permission || declared.has(permission)) return
38
37
  const isActiveTabQuery = permission == 'tabs' && queriesActiveTab(node)
39
38
  if (isActiveTabQuery && declared.has('activeTab')) return
40
39
  const messageId = `missing${ isActiveTabQuery ? 'ActiveTab' : 'Permission' }`
@@ -16,8 +16,8 @@ function collectWARpatterns(manifest) {
16
16
  }
17
17
 
18
18
  function patternToRegex(pattern) {
19
- const escaped = pattern.replace(re.regexSpecial, '\\$&').replace(/\*/g, '.*')
20
- return new RegExp(`^${escaped}$`) // eslint-disable-line security/detect-non-literal-regexp
19
+ const escaped = pattern.replaceAll(re.regexSpecial, '\\$&').replaceAll('*', '.*')
20
+ return new RegExp(`^${escaped}$`) // oxlint-disable-line security/detect-non-literal-regexp
21
21
  }
22
22
 
23
23
  export default {
@@ -3,6 +3,38 @@ import nodeUtils from '../utils/node.js'
3
3
  import manifest from '../data/manifest.js'
4
4
  import regex from '../data/regex.js'
5
5
 
6
+ const re = { inlineHandler: /^on[a-z]+$/i, inlineHandlerInHTML: /\son[a-z]+\s*=/i }
7
+
8
+ function isCreateScript(node) {
9
+ return node?.type == 'CallExpression'
10
+ && node.callee?.property?.name == 'createElement'
11
+ && node.arguments[0]?.value == 'script'
12
+ }
13
+ function isInlineHandlerInString(node) {
14
+ return node?.type == 'Literal'
15
+ && typeof node.value == 'string'
16
+ && re.inlineHandlerInHTML.test(node.value)
17
+ }
18
+ function isLiteralURL(node) {
19
+ return node?.type == 'Literal'
20
+ && typeof node.value == 'string'
21
+ && regex.url.test(node.value)
22
+ }
23
+ function isObjectAssign(node) {
24
+ return node?.type == 'CallExpression'
25
+ && node.callee?.type == 'MemberExpression'
26
+ && node.callee.object?.name == 'Object'
27
+ && node.callee.property?.name == 'assign'
28
+ }
29
+ function isScriptSrcProperty(node) {
30
+ const obj = node.parent
31
+ if (obj?.type != 'ObjectExpression') return false
32
+ const parent = obj.parent
33
+ if (isCreateScript(parent)) return true
34
+ if (isObjectAssign(parent) && isCreateScript(parent.arguments[0])) return true
35
+ return false
36
+ }
37
+
6
38
  export default {
7
39
  meta: {
8
40
  type: 'problem',
@@ -13,8 +45,7 @@ export default {
13
45
  security: true
14
46
  },
15
47
  defaultOptions: [{
16
- patterns: ['eval', 'new-function', 'string-timer',
17
- 'dynamic-import', 'remote-script-src', 'execute-script-url'],
48
+ patterns: manifest.patterns.remoteCode,
18
49
  checkExecuteScriptClosures: true
19
50
  }],
20
51
  schema: [{
@@ -39,31 +70,17 @@ export default {
39
70
  dynamicImport: 'Remote dynamic `import()` is forbidden under MV3\'s CSP. Use a relative path instead.',
40
71
  remoteScriptSrc: 'Injecting remote `<script src>` is forbidden under MV3\'s CSP. Use `chrome.runtime.getURL()` for bundled scripts instead.',
41
72
  executeScriptURL: '`chrome.scripting.executeScript` must use local `files`, not a remote URL.',
42
- executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.'
73
+ executeScriptClosure: '`chrome.scripting.executeScript({ func })` captures outer-scope variables that fail after serialization. Pass them via `args` instead.',
74
+ inlineEventHandler: 'Inline event handler `{{attr}}` is blocked by MV3\'s `script-src \'self\'`. Use `addEventListener()` instead.',
75
+ inlineScriptBody: 'Inline `<script>` body injection is blocked by MV3\'s `script-src \'self\'`. Move the code to an external file.'
43
76
  }
44
77
  },
45
78
 
46
79
  create(context) {
47
80
  const opts = context.options[0] || {},
48
- patterns = new Set(opts.patterns || manifest.regexmoteCode),
81
+ patterns = new Set(opts.patterns || manifest.patterns.remoteCode),
49
82
  checkClosures = opts.checkExecuteScriptClosures !== false,
50
83
  scriptElems = new Set()
51
- function isCreateScript(node) {
52
- return node?.type == 'CallExpression'
53
- && node.callee?.property?.name == 'createElement'
54
- && node.arguments[0]?.value == 'script'
55
- }
56
- function isLiteralURL(node) {
57
- return node?.type == 'Literal'
58
- && typeof node.value == 'string'
59
- && regex.url.test(node.value)
60
- }
61
- function isObjectAssign(node) {
62
- return node?.type == 'CallExpression'
63
- && node.callee?.type == 'MemberExpression'
64
- && node.callee.object?.name == 'Object'
65
- && node.callee.property?.name == 'assign'
66
- }
67
84
  function isScriptSrcAssignment(left, right) {
68
85
  if (left.type != 'MemberExpression') return false
69
86
  if (left.property?.name != 'src') return false
@@ -72,12 +89,10 @@ export default {
72
89
  if (left.object.type == 'Identifier' && scriptElems.has(left.object.name)) return true
73
90
  return false
74
91
  }
75
- function isScriptSrcProperty(node) {
76
- const obj = node.parent
77
- if (obj?.type != 'ObjectExpression') return false
78
- const parent = obj.parent
79
- if (isCreateScript(parent)) return true
80
- if (isObjectAssign(parent) && isCreateScript(parent.arguments[0])) return true
92
+ function isScriptElement(node) {
93
+ if (!node) return false
94
+ if (isCreateScript(node)) return true
95
+ if (node.type == 'Identifier' && scriptElems.has(node.name)) return true
81
96
  return false
82
97
  }
83
98
  return {
@@ -112,12 +127,31 @@ export default {
112
127
  if (optsNode?.type != 'ObjectExpression') return
113
128
  const urlProp = optsNode.properties.find(prop => prop.key?.name == 'url'),
114
129
  funcProp = optsNode.properties.find(prop => prop.key?.name == 'func'),
115
- argsProp = optsNode.properties.find(prop => prop.key?.name == 'args')
130
+ hasArgs = optsNode.properties.some(prop => prop.key?.name == 'args')
116
131
  if (patterns.has('execute-script-url') && urlProp)
117
132
  context.report({ node: urlProp, messageId: 'executeScriptURL' })
118
- if (checkClosures && funcProp && !argsProp)
133
+ if (checkClosures && funcProp && !hasArgs)
119
134
  context.report({ node: funcProp, messageId: 'executeScriptClosure' })
120
135
  }
136
+ if (patterns.has('inline-event-handler')
137
+ && node.callee.property?.name == 'setAttribute'
138
+ && node.arguments[0]?.type == 'Literal'
139
+ && typeof node.arguments[0].value == 'string'
140
+ && re.inlineHandler.test(node.arguments[0].value)
141
+ ) context.report({ node, messageId: 'inlineEventHandler',
142
+ data: { attr: node.arguments[0].value.toLowerCase() }})
143
+ if (patterns.has('inline-event-handler')
144
+ && node.callee.property?.name == 'insertAdjacentHTML'
145
+ && isInlineHandlerInString(node.arguments[1])
146
+ ) context.report({ node: node.arguments[1], messageId: 'inlineEventHandler',
147
+ data: { attr: 'on*' }})
148
+ if (patterns.has('inline-script-body')
149
+ && ['append', 'prepend'].includes(node.callee.property?.name)
150
+ && isScriptElement(node.callee.object)
151
+ && node.arguments[0]?.type == 'Literal'
152
+ && typeof node.arguments[0].value == 'string'
153
+ && node.arguments[0].value.trim().length > 0
154
+ ) context.report({ node: node.arguments[0], messageId: 'inlineScriptBody' })
121
155
  }
122
156
  },
123
157
  NewExpression(node) {
@@ -125,6 +159,21 @@ export default {
125
159
  context.report({ node, messageId: 'newFunction' })
126
160
  },
127
161
  AssignmentExpression(node) {
162
+ if (patterns.has('inline-event-handler')
163
+ && node.left.type == 'MemberExpression'
164
+ && ['innerHTML', 'outerHTML'].includes(node.left.property?.name)
165
+ && !isScriptElement(node.left.object)
166
+ && isInlineHandlerInString(node.right)
167
+ ) context.report({ node: node.right, messageId: 'inlineEventHandler',
168
+ data: { attr: 'on*' }})
169
+ if (patterns.has('inline-script-body')
170
+ && node.left.type == 'MemberExpression'
171
+ && ['textContent', 'innerText', 'innerHTML', 'text'].includes(node.left.property?.name)
172
+ && isScriptElement(node.left.object)
173
+ && node.right?.type == 'Literal'
174
+ && typeof node.right.value == 'string'
175
+ && node.right.value.trim().length > 0
176
+ ) context.report({ node, messageId: 'inlineScriptBody' })
128
177
  if (!patterns.has('remote-script-src')) return
129
178
  if (isScriptSrcAssignment(node.left, node.right))
130
179
  context.report({ node: node.right, messageId: 'remoteScriptSrc' })
@@ -0,0 +1,55 @@
1
+ import array from '../utils/array.js'
2
+ import html from '../utils/html.js'
3
+
4
+ const re = { objectTag: /<(?:object|embed)\b([^>]*)>/gi }
5
+
6
+ export default {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: {
10
+ description: 'Disallow remote `<object data>` and `<embed src>` in extension HTML',
11
+ category: 'Possible Errors',
12
+ html: true
13
+ },
14
+ defaultOptions: [{ allowedPrefixes: ['chrome-extension://'] }],
15
+ schema: [{
16
+ type: 'object',
17
+ properties: {
18
+ allowedPrefixes: {
19
+ oneOf: [
20
+ { type: 'string', description: 'A single allowed prefix' },
21
+ { type: 'array', items: { type: 'string' }, description: 'List of allowed prefixes' }
22
+ ],
23
+ description: 'Additional prefixes to permit'
24
+ }
25
+ },
26
+ additionalProperties: false
27
+ }],
28
+ messages: {
29
+ remoteObject: 'Remote `<{{tag}} {{attr}}="{{src}}">` is blocked by MV3\'s `object-src \'self\'`. '
30
+ + 'Bundle the resource or use `chrome.runtime.getURL()`.'
31
+ }
32
+ },
33
+
34
+ create(context) {
35
+ const opts = context.options[0] || {},
36
+ allowedPrefixes = array.to(opts.allowedPrefixes ?? ['chrome-extension://']),
37
+ text = context.sourceCode.text
38
+ let scanned = false
39
+ function scan() {
40
+ if (scanned) return
41
+ scanned = true
42
+ if (!html.isHTML(text)) return
43
+ for (const match of text.matchAll(re.objectTag)) {
44
+ const [full, attrs] = match,
45
+ tag = full.trimStart().toLowerCase().startsWith('<object') ? 'object' : 'embed',
46
+ attr = tag == 'object' ? 'data' : 'src',
47
+ val = html.pickAttrVal(attrs.match(tag == 'object' ? html.re.attr.data : html.re.attr.src))
48
+ if (val !== null && html.isRemote(val.trim(), allowedPrefixes))
49
+ context.report({ loc: html.offsetToLoc(text, match.index), messageId: 'remoteObject',
50
+ data: { tag, attr, src: val.trim() }})
51
+ }
52
+ }
53
+ return { 'Program:exit': scan, 'Document:exit': scan, '*': scan }
54
+ }
55
+ }
@@ -0,0 +1,102 @@
1
+ import array from '../utils/array.js'
2
+ import regex from '../data/regex.js'
3
+
4
+ function getPreludeURLnode(prelude) {
5
+ if (!prelude) return null
6
+ if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(prelude.type))
7
+ return prelude
8
+ for (const child of prelude.children ?? [])
9
+ if (['Url', 'CSSUrl', 'String', 'CSSString'].includes(child.type))
10
+ return child
11
+ return null
12
+ }
13
+ function getURLstr(node) {
14
+ if (!node) return null
15
+ if (typeof node == 'string') return node
16
+ if (typeof node.url == 'string') return node.url
17
+ if (typeof node.value == 'string') return node.value
18
+ if (node.value && typeof node.value == 'object' && typeof node.value.value == 'string')
19
+ return node.value.value
20
+ return null
21
+ }
22
+
23
+ export default {
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description: 'Disallow remote resources (`@import`, `url()`, `@namespace`) in extension CSS',
28
+ category: 'Possible Errors',
29
+ css: true
30
+ },
31
+ defaultOptions: [{
32
+ allowedSchemes: ['blob', 'chrome-extension', 'data'],
33
+ patterns: ['at-import', 'url']
34
+ }],
35
+ schema: [{
36
+ type: 'object',
37
+ properties: {
38
+ allowedSchemes: {
39
+ oneOf: [
40
+ { type: 'string', description: 'A single URL scheme' },
41
+ { type: 'array', items: { type: 'string' }, description: 'List of URL schemes' }
42
+ ],
43
+ description: 'URL schemes to allow (e.g. `chrome-extension`, `data`, `blob`)'
44
+ },
45
+ patterns: {
46
+ oneOf: [
47
+ { enum: ['at-import', 'url', 'namespace'], description: 'A single detection path' },
48
+ { type: 'array', items: { enum: ['at-import', 'url', 'namespace'] }, description: 'List of detection paths' }
49
+ ],
50
+ description: 'Detection paths to enable'
51
+ }
52
+ },
53
+ additionalProperties: false
54
+ }],
55
+ messages: {
56
+ remoteImport: 'Remote `@import` is disallowed. Bundle the stylesheet or use `chrome.runtime.getURL()`.',
57
+ remoteURL: 'Remote `url()` is disallowed. Bundle the asset or use `chrome.runtime.getURL()`.',
58
+ remoteNamespace: 'Remote `@namespace` is disallowed. Reference a bundled stylesheet.'
59
+ }
60
+ },
61
+
62
+ create(context) {
63
+ const opts = context.options[0] || {},
64
+ allowedSchemes = new Set(array.to(opts.allowedSchemes ?? ['blob', 'chrome-extension', 'data'])),
65
+ patterns = new Set(array.to(opts.patterns ?? ['at-import', 'url'])),
66
+ handledURLs = new WeakSet()
67
+ function handleAtrule(node) {
68
+ if (!node.prelude) return
69
+ if (node.name == 'import' && patterns.has('at-import')) {
70
+ const urlNode = getPreludeURLnode(node.prelude)
71
+ if (urlNode && isRemote(getURLstr(urlNode))) {
72
+ handledURLs.add(urlNode)
73
+ context.report({ node, messageId: 'remoteImport' })
74
+ }
75
+ }
76
+ if (node.name == 'namespace' && patterns.has('namespace')) {
77
+ const urlNode = getPreludeURLnode(node.prelude)
78
+ if (urlNode && isRemote(getURLstr(urlNode))) {
79
+ handledURLs.add(urlNode)
80
+ context.report({ node, messageId: 'remoteNamespace' })
81
+ }
82
+ }
83
+ }
84
+ function handleURL(node) {
85
+ if (!patterns.has('url')) return
86
+ if (handledURLs.has(node)) return
87
+ if (node.parent?.type == 'AtrulePrelude' || node.parent?.type == 'CSSAtRulePrelude') return
88
+ if (isRemote(getURLstr(node)))
89
+ context.report({ node, messageId: 'remoteURL' })
90
+ }
91
+ function isRemote(str) {
92
+ if (typeof str != 'string') return false
93
+ const match = regex.scheme.exec(str)
94
+ if (!match) return false
95
+ if (match[0] == '//') return true
96
+ const scheme = (match[1] || '').toLowerCase()
97
+ if (['http', 'https', 'ftp'].includes(scheme)) return true
98
+ return !allowedSchemes.has(scheme)
99
+ }
100
+ return { Atrule: handleAtrule, CSSAtRule: handleAtrule, Url: handleURL, CSSUrl: handleURL }
101
+ }
102
+ }