From 62a1405bab6cbcb11f64152cf8d6178f90902554 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 15:52:17 +0200 Subject: [PATCH 01/12] USER-2389 Support Window onmessage in S2819 --- .../analysis/src/jsts/rules/S2819/rule.ts | 38 ++++++++++- .../src/jsts/rules/S2819/unit.test.ts | 63 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index ff1286341c9..2ddfd0bd6f3 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -24,6 +24,7 @@ import { generateMeta } from '../helpers/generate-meta.js'; import { getTypeAsString } from '../helpers/type.js'; import { getValueOfExpression, + getUniqueWriteUsageOrNode, isIdentifier, isIfStatement, resolveFunction, @@ -33,6 +34,7 @@ import * as meta from './generated-meta.js'; const POST_MESSAGE = 'postMessage'; const ADD_EVENT_LISTENER = 'addEventListener'; +const ON_MESSAGE = 'onmessage'; export const rule: Rule.RuleModule = { meta: generateMeta(meta, { @@ -54,6 +56,9 @@ export const rule: Rule.RuleModule = { [`CallExpression[callee.property.name="${ADD_EVENT_LISTENER}"]`]: (node: estree.Node) => { checkAddEventListenerCall(node as estree.CallExpression, context); }, + AssignmentExpression: (node: estree.Node) => { + checkOnMessageAssignment(node as estree.AssignmentExpression, context); + }, }; }, }; @@ -100,9 +105,36 @@ function checkAddEventListenerCall(callExpr: estree.CallExpression, context: Rul return; } - let listener = resolveFunction(context, args[1]); + checkMessageListener(context, args[1], callee); +} + +function checkOnMessageAssignment( + assignment: estree.AssignmentExpression, + context: Rule.RuleContext, +) { + const { left } = assignment; + if ( + assignment.operator !== '=' || + left.type !== 'MemberExpression' || + left.computed || + left.property.type !== 'Identifier' || + left.property.name !== ON_MESSAGE || + !isWindowObject(left.object, context) + ) { + return; + } + + checkMessageListener(context, assignment.right, left); +} + +function checkMessageListener( + context: Rule.RuleContext, + listenerNode: estree.Node, + reportNode: estree.Node, +) { + let listener = resolveFunction(context, getUniqueWriteUsageOrNode(context, listenerNode)); if (listener?.body.type === 'CallExpression') { - listener = resolveFunction(context, listener.body); + listener = resolveFunction(context, getUniqueWriteUsageOrNode(context, listener.body)); } if (!listener || listener.params.length === 0) { return; @@ -115,7 +147,7 @@ function checkAddEventListenerCall(callExpr: estree.CallExpression, context: Rul if (!hasVerifiedOrigin(context, listener, event)) { context.report({ - node: callee, + node: reportNode, messageId: 'verifyOrigin', }); } diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 1189e0459e8..d79e0390c1d 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -68,6 +68,42 @@ describe('S2819', () => { }, { code: ` + window.onmessage = function(event) { + if (event.origin !== "http://example.org") + return; + console.log(event.data); + }; + `, + }, + { + code: ` + function handleMessage(event) { + if (event.origin !== "http://example.org") + return; + } + globalThis.onmessage = handleMessage; + `, + }, + { + code: ` + const target = window; + const handleMessage = event => { + if (event.origin !== "http://example.org") + return; + }; + target.onmessage = handleMessage; + `, + }, + { + code: ` + const socket = new WebSocket('wss://example.org'); + socket.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` window.addEventListener("missing listener"); window.addEventListener("message", "not a function"); not_a_win_dow.addEventListener("message", () => {}); @@ -195,6 +231,33 @@ describe('S2819', () => { }, { code: ` + window.onmessage = function(event) { + console.log(event.data); + }; + `, + errors: [{ messageId: 'verifyOrigin' }], + }, + { + code: ` + function handleMessage(event) { + console.log(event.data); + } + globalThis.onmessage = handleMessage; + `, + errors: [{ messageId: 'verifyOrigin' }], + }, + { + code: ` + const target = window; + const handleMessage = function (event) { + console.log(event.data); + }; + target.onmessage = handleMessage; + `, + errors: [{ messageId: 'verifyOrigin' }], + }, + { + code: ` function eventHandler(event) { console.log(event.data); } From f74199a1b8f7fb14ff730843bc43c97d4b3efaa9 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 16:26:37 +0200 Subject: [PATCH 02/12] JS-2205 Avoid non-Window onmessage false positives --- packages/analysis/src/jsts/rules/S2819/rule.ts | 11 ++++++++--- packages/analysis/src/jsts/rules/S2819/unit.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 2ddfd0bd6f3..60b240d9505 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -63,9 +63,14 @@ export const rule: Rule.RuleModule = { }, }; -function isWindowObject(node: estree.Node, context: Rule.RuleContext) { +function isWindowObject( + node: estree.Node, + context: Rule.RuleContext, + allowWindowNameMatch = true, +) { const type = getTypeAsString(node, context.sourceCode.parserServices); - const hasWindowName = WindowNameVisitor.containsWindowName(node, context); + const hasWindowName = + allowWindowNameMatch && WindowNameVisitor.containsWindowName(node, context); return type.match(/window/i) || type.match(/globalThis/i) || hasWindowName; } @@ -119,7 +124,7 @@ function checkOnMessageAssignment( left.computed || left.property.type !== 'Identifier' || left.property.name !== ON_MESSAGE || - !isWindowObject(left.object, context) + !isWindowObject(left.object, context, false) ) { return; } diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index d79e0390c1d..3ed8314ccf9 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -104,6 +104,14 @@ describe('S2819', () => { }, { code: ` + const eventWindowChannel = new WebSocket('wss://example.org'); + eventWindowChannel.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` window.addEventListener("missing listener"); window.addEventListener("message", "not a function"); not_a_win_dow.addEventListener("message", () => {}); From b1fe275dbbe98d096671f5bbf29cf5578effd044 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Aug 2026 14:06:52 +0000 Subject: [PATCH 03/12] Update ruling results Generated with GitHub Actions --- its/ruling/src/test/expected/ace/javascript-S2819.json | 6 ++++++ .../src/test/expected/angular.js/javascript-S2819.json | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 its/ruling/src/test/expected/angular.js/javascript-S2819.json diff --git a/its/ruling/src/test/expected/ace/javascript-S2819.json b/its/ruling/src/test/expected/ace/javascript-S2819.json index 717c8938c7d..2ae03ff9bb1 100644 --- a/its/ruling/src/test/expected/ace/javascript-S2819.json +++ b/its/ruling/src/test/expected/ace/javascript-S2819.json @@ -2,5 +2,11 @@ "ace:lib/ace/mode/html/saxparser.js": [ 10389, 10402 +], +"ace:lib/ace/worker/worker.js": [ +197 +], +"ace:lib/ace/worker/worker_v2.js": [ +71 ] } diff --git a/its/ruling/src/test/expected/angular.js/javascript-S2819.json b/its/ruling/src/test/expected/angular.js/javascript-S2819.json new file mode 100644 index 00000000000..d522ee78f56 --- /dev/null +++ b/its/ruling/src/test/expected/angular.js/javascript-S2819.json @@ -0,0 +1,5 @@ +{ +"angular.js:docs/app/assets/js/search-worker.js": [ +35 +] +} From b249718d8970c1dcafe82b6374dec08815a72965 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 17:22:14 +0200 Subject: [PATCH 04/12] JS-2205 Restrict S2819 onmessage to Window --- .../test/expected/ace/javascript-S2819.json | 6 ------ .../expected/angular.js/javascript-S2819.json | 5 ----- packages/analysis/src/jsts/rules/S2819/rule.ts | 17 ++++++++++++++++- .../analysis/src/jsts/rules/S2819/unit.test.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 12 deletions(-) delete mode 100644 its/ruling/src/test/expected/angular.js/javascript-S2819.json diff --git a/its/ruling/src/test/expected/ace/javascript-S2819.json b/its/ruling/src/test/expected/ace/javascript-S2819.json index 2ae03ff9bb1..717c8938c7d 100644 --- a/its/ruling/src/test/expected/ace/javascript-S2819.json +++ b/its/ruling/src/test/expected/ace/javascript-S2819.json @@ -2,11 +2,5 @@ "ace:lib/ace/mode/html/saxparser.js": [ 10389, 10402 -], -"ace:lib/ace/worker/worker.js": [ -197 -], -"ace:lib/ace/worker/worker_v2.js": [ -71 ] } diff --git a/its/ruling/src/test/expected/angular.js/javascript-S2819.json b/its/ruling/src/test/expected/angular.js/javascript-S2819.json deleted file mode 100644 index d522ee78f56..00000000000 --- a/its/ruling/src/test/expected/angular.js/javascript-S2819.json +++ /dev/null @@ -1,5 +0,0 @@ -{ -"angular.js:docs/app/assets/js/search-worker.js": [ -35 -] -} diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 60b240d9505..5b7a407a409 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -124,7 +124,7 @@ function checkOnMessageAssignment( left.computed || left.property.type !== 'Identifier' || left.property.name !== ON_MESSAGE || - !isWindowObject(left.object, context, false) + !isWindowMessageReceiver(left.object, context) ) { return; } @@ -132,6 +132,21 @@ function checkOnMessageAssignment( checkMessageListener(context, assignment.right, left); } +function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { + if (isWorkerEnvironment(context) || (node.type === 'Identifier' && node.name === 'self')) { + return false; + } + + const type = getTypeAsString(node, context.sourceCode.parserServices); + return type.match(/window/i) || (node.type === 'Identifier' && node.name === 'globalThis'); +} + +function isWorkerEnvironment(context: Rule.RuleContext) { + return context.sourceCode + .getAllComments() + .some(comment => /eslint-env\s+.*\bworker\b/i.test(comment.value)); +} + function checkMessageListener( context: Rule.RuleContext, listenerNode: estree.Node, diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 3ed8314ccf9..5c0ae6ebef3 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -112,6 +112,24 @@ describe('S2819', () => { }, { code: ` + /* eslint-env worker */ + self.onmessage = function(event) { + console.log(event.data); + }; + window.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` + self.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` window.addEventListener("missing listener"); window.addEventListener("message", "not a function"); not_a_win_dow.addEventListener("message", () => {}); From b128d81c29536606a99a758a27712a7777a63436 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 17:24:34 +0200 Subject: [PATCH 05/12] JS-2205 Exclude Worker aliases from S2819 --- packages/analysis/src/jsts/rules/S2819/rule.ts | 9 ++++++--- packages/analysis/src/jsts/rules/S2819/unit.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 5b7a407a409..9056d5a4d75 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -133,12 +133,15 @@ function checkOnMessageAssignment( } function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { - if (isWorkerEnvironment(context) || (node.type === 'Identifier' && node.name === 'self')) { + if (isWorkerEnvironment(context)) { return false; } - const type = getTypeAsString(node, context.sourceCode.parserServices); - return type.match(/window/i) || (node.type === 'Identifier' && node.name === 'globalThis'); + const receiver = getUniqueWriteUsageOrNode(context, node, true); + return ( + receiver.type === 'Identifier' && + (receiver.name === 'window' || receiver.name === 'globalThis') + ); } function isWorkerEnvironment(context: Rule.RuleContext) { diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 5c0ae6ebef3..760975becf0 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -130,6 +130,14 @@ describe('S2819', () => { }, { code: ` + const target = self; + target.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` window.addEventListener("missing listener"); window.addEventListener("message", "not a function"); not_a_win_dow.addEventListener("message", () => {}); From 633326c11b036241522817ab1e32136cf2316ee4 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 17:26:54 +0200 Subject: [PATCH 06/12] JS-2205 Verify Window receiver for S2819 --- packages/analysis/src/jsts/rules/S2819/rule.ts | 18 ++++++++++++++---- .../analysis/src/jsts/rules/S2819/unit.test.ts | 10 ++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 9056d5a4d75..c2d29c4c1c1 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -21,7 +21,7 @@ import type estree from 'estree'; import type { TSESTree } from '@typescript-eslint/utils'; import { childrenOf, findFirstMatchingLocalAncestor } from '../helpers/ancestor.js'; import { generateMeta } from '../helpers/generate-meta.js'; -import { getTypeAsString } from '../helpers/type.js'; +import { getTypeAsString, getTypeFromTreeNode } from '../helpers/type.js'; import { getValueOfExpression, getUniqueWriteUsageOrNode, @@ -138,9 +138,19 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { } const receiver = getUniqueWriteUsageOrNode(context, node, true); - return ( - receiver.type === 'Identifier' && - (receiver.name === 'window' || receiver.name === 'globalThis') + if ( + receiver.type !== 'Identifier' || + (receiver.name !== 'window' && receiver.name !== 'globalThis') + ) { + return false; + } + + const onMessage = getTypeFromTreeNode( + receiver, + context.sourceCode.parserServices, + ).getProperty(ON_MESSAGE); + return onMessage?.declarations?.some(declaration => + declaration.getSourceFile().fileName.endsWith('lib.dom.d.ts'), ); } diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 760975becf0..2219e2cb4b1 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -138,6 +138,16 @@ describe('S2819', () => { }, { code: ` + type WorkerScope = { onmessage: (event: MessageEvent) => void }; + function register(globalThis: WorkerScope) { + globalThis.onmessage = function(event) { + console.log(event.data); + }; + } + `, + }, + { + code: ` window.addEventListener("missing listener"); window.addEventListener("message", "not a function"); not_a_win_dow.addEventListener("message", () => {}); From f464fd07c650b5274be22f4589520c1ef154ac87 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Mon, 3 Aug 2026 23:04:20 +0200 Subject: [PATCH 07/12] JS-2205 Exclude aliased Worker globals from S2819 --- .../analysis/src/jsts/rules/S2819/rule.ts | 20 +++++++++++++++- .../src/jsts/rules/S2819/unit.test.ts | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index c2d29c4c1c1..891ec2ca06f 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -27,6 +27,7 @@ import { getUniqueWriteUsageOrNode, isIdentifier, isIfStatement, + functionLike, resolveFunction, } from '../helpers/ast.js'; import { isRequiredParserServices } from '../helpers/parser-services.js'; @@ -133,7 +134,7 @@ function checkOnMessageAssignment( } function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { - if (isWorkerEnvironment(context)) { + if (isWorkerEnvironment(context) || isWindowAliasedToWorkerGlobal(node, context)) { return false; } @@ -154,6 +155,23 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { ); } +function isWindowAliasedToWorkerGlobal(node: estree.Node, context: Rule.RuleContext) { + const receiverFunction = findFirstMatchingLocalAncestor( + node as TSESTree.Node, + ancestor => functionLike.has(ancestor.type), + ); + return context.sourceCode.getScope(node).through.some( + reference => + reference.isWrite() && + reference.identifier.name === 'window' && + findFirstMatchingLocalAncestor(reference.identifier as TSESTree.Node, ancestor => + functionLike.has(ancestor.type), + ) === receiverFunction && + (isIdentifier(reference.writeExpr ?? undefined, 'self') || + isIdentifier(reference.writeExpr ?? undefined, 'global')), + ); +} + function isWorkerEnvironment(context: Rule.RuleContext) { return context.sourceCode .getAllComments() diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 2219e2cb4b1..1124888b2eb 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -138,6 +138,16 @@ describe('S2819', () => { }, { code: ` + if (typeof window === 'undefined') { + window = self; + } + window.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + code: ` type WorkerScope = { onmessage: (event: MessageEvent) => void }; function register(globalThis: WorkerScope) { globalThis.onmessage = function(event) { @@ -275,6 +285,19 @@ describe('S2819', () => { }, { code: ` + window.onmessage = function(event) { + console.log(event.data); + }; + `, + errors: [{ messageId: 'verifyOrigin' }], + }, + { + code: ` + function bootstrapWorker() { + if (typeof window === 'undefined') { + window = self; + } + } window.onmessage = function(event) { console.log(event.data); }; From 9feb03e0e3744e02c3313d7d72b91e589aed2948 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Tue, 4 Aug 2026 09:14:22 +0200 Subject: [PATCH 08/12] JS-2205 Avoid regex backtracking in S2819 --- packages/analysis/src/jsts/rules/S2819/rule.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 891ec2ca06f..751eb0a3e39 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -173,9 +173,20 @@ function isWindowAliasedToWorkerGlobal(node: estree.Node, context: Rule.RuleCont } function isWorkerEnvironment(context: Rule.RuleContext) { - return context.sourceCode - .getAllComments() - .some(comment => /eslint-env\s+.*\bworker\b/i.test(comment.value)); + const eslintEnv = 'eslint-env'; + return context.sourceCode.getAllComments().some(comment => { + const content = comment.value.toLowerCase(); + const envStart = content.indexOf(eslintEnv); + const variablesStart = envStart + eslintEnv.length; + return ( + envStart >= 0 && + /\s/.test(content[variablesStart] ?? '') && + content + .slice(variablesStart) + .split(/[\s,]+/) + .includes('worker') + ); + }); } function checkMessageListener( From 88d925a4526f0248c475ffb70787cdec4c0be185 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Tue, 4 Aug 2026 10:56:57 +0200 Subject: [PATCH 09/12] JS-2205 Address S2819 onmessage review feedback Drop the eslint-env worker opt-out: the comment form is ignored under flat config and becomes an error in ESLint 10, and the lib.dom.d.ts check already suppresses worker and Node projects. Revert the unused isWindowObject parameter, drop a no-op unique-write lookup, and document the receiver restriction and the `through` invariant. Cover the operator and computed-property guards, non-function right-hand sides, and listener resolution through a const arrow function. Co-Authored-By: Claude Opus 5 (1M context) --- .../analysis/src/jsts/rules/S2819/rule.ts | 87 ++++++++----------- .../src/jsts/rules/S2819/unit.test.ts | 54 +++++++++++- 2 files changed, 89 insertions(+), 52 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 751eb0a3e39..0c38c266fc7 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -64,14 +64,9 @@ export const rule: Rule.RuleModule = { }, }; -function isWindowObject( - node: estree.Node, - context: Rule.RuleContext, - allowWindowNameMatch = true, -) { +function isWindowObject(node: estree.Node, context: Rule.RuleContext) { const type = getTypeAsString(node, context.sourceCode.parserServices); - const hasWindowName = - allowWindowNameMatch && WindowNameVisitor.containsWindowName(node, context); + const hasWindowName = WindowNameVisitor.containsWindowName(node, context); return type.match(/window/i) || type.match(/globalThis/i) || hasWindowName; } @@ -133,8 +128,15 @@ function checkOnMessageAssignment( checkMessageListener(context, assignment.right, left); } +/** + * Unlike `isWindowObject`, which also accepts any identifier whose name contains 'window', + * the receiver of an `onmessage` assignment must resolve to `window` or `globalThis` itself. + * The name heuristic is too coarse here: `onmessage` is also a property of unrelated + * transports such as WebSocket, so a `wsWindowChannel.onmessage` assignment would be + * reported without this restriction. + */ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { - if (isWorkerEnvironment(context) || isWindowAliasedToWorkerGlobal(node, context)) { + if (isWindowAliasedToWorkerGlobal(node, context)) { return false; } @@ -146,47 +148,37 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { return false; } - const onMessage = getTypeFromTreeNode( - receiver, - context.sourceCode.parserServices, - ).getProperty(ON_MESSAGE); + const onMessage = getTypeFromTreeNode(receiver, context.sourceCode.parserServices).getProperty( + ON_MESSAGE, + ); return onMessage?.declarations?.some(declaration => declaration.getSourceFile().fileName.endsWith('lib.dom.d.ts'), ); } +/** + * Detects `window = self` / `window = global` shims used by scripts meant to run in a Worker. + * + * Such a write only surfaces in `through` as long as `window` is an unresolved reference, which + * is the case for the global scope of an analyzed file. The write is only relevant when it can + * reach the receiver, hence the comparison of enclosing functions: a shim confined to another + * function does not make the receiver a Worker global. + */ function isWindowAliasedToWorkerGlobal(node: estree.Node, context: Rule.RuleContext) { - const receiverFunction = findFirstMatchingLocalAncestor( - node as TSESTree.Node, - ancestor => functionLike.has(ancestor.type), + const receiverFunction = findFirstMatchingLocalAncestor(node as TSESTree.Node, ancestor => + functionLike.has(ancestor.type), ); - return context.sourceCode.getScope(node).through.some( - reference => - reference.isWrite() && - reference.identifier.name === 'window' && - findFirstMatchingLocalAncestor(reference.identifier as TSESTree.Node, ancestor => - functionLike.has(ancestor.type), - ) === receiverFunction && - (isIdentifier(reference.writeExpr ?? undefined, 'self') || - isIdentifier(reference.writeExpr ?? undefined, 'global')), - ); -} - -function isWorkerEnvironment(context: Rule.RuleContext) { - const eslintEnv = 'eslint-env'; - return context.sourceCode.getAllComments().some(comment => { - const content = comment.value.toLowerCase(); - const envStart = content.indexOf(eslintEnv); - const variablesStart = envStart + eslintEnv.length; - return ( - envStart >= 0 && - /\s/.test(content[variablesStart] ?? '') && - content - .slice(variablesStart) - .split(/[\s,]+/) - .includes('worker') + return context.sourceCode + .getScope(node) + .through.some( + reference => + reference.isWrite() && + reference.identifier.name === 'window' && + findFirstMatchingLocalAncestor(reference.identifier as TSESTree.Node, ancestor => + functionLike.has(ancestor.type), + ) === receiverFunction && + isIdentifier(reference.writeExpr ?? undefined, 'self', 'global'), ); - }); } function checkMessageListener( @@ -196,7 +188,7 @@ function checkMessageListener( ) { let listener = resolveFunction(context, getUniqueWriteUsageOrNode(context, listenerNode)); if (listener?.body.type === 'CallExpression') { - listener = resolveFunction(context, getUniqueWriteUsageOrNode(context, listener.body)); + listener = resolveFunction(context, listener.body); } if (!listener || listener.params.length === 0) { return; @@ -275,9 +267,9 @@ function hasVerifiedOrigin( function findUnionOrigin(eventRef: TSESTree.Node, eventIdentifiers: TSESTree.Identifier[]) { const memberExpr = eventRef.parent; // looks for event.origin in a LogicalExpr - if ( - !(memberExpr?.type === 'MemberExpression' && memberExpr.parent?.type === 'LogicalExpression') - ) { + if (!( + memberExpr?.type === 'MemberExpression' && memberExpr.parent?.type === 'LogicalExpression' + )) { return null; } const logicalExpr = memberExpr.parent; @@ -437,10 +429,7 @@ function findEventOriginalEvent(event: TSESTree.Identifier) { return null; } const { object: eventCandidate, property: originalEventIdentifierCandidate } = memberExpr; - if ( - eventCandidate === event && - isIdentifier(originalEventIdentifierCandidate, 'originalEvent') - ) { + if (eventCandidate === event && isIdentifier(originalEventIdentifierCandidate, 'originalEvent')) { return memberExpr; } return null; diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index 1124888b2eb..f4b5e3fe590 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -111,18 +111,57 @@ describe('S2819', () => { `, }, { + // compound assignments are out of scope code: ` - /* eslint-env worker */ - self.onmessage = function(event) { + window.onmessage ??= function(event) { console.log(event.data); }; - window.onmessage = function(event) { + window.onmessage ||= function(event) { + console.log(event.data); + }; + `, + }, + { + // computed properties are out of scope + code: ` + window['onmessage'] = function(event) { + console.log(event.data); + }; + const property = 'onmessage'; + window[property] = function(event) { console.log(event.data); }; `, }, { code: ` + window.onmessage = null; + window.onmessage = "not a function"; + window.onmessage = function() {}; // missing event parameter + window.onmessage = function(...not_an_identifier) {}; + `, + }, + { + code: ` + window.onmessage = function(event) { + const e = event || event.originalEvent; + if (e.origin !== "http://example.org") + return; + console.log(e.data); + }; + `, + }, + { + code: ` + const handleMessage = event => { + if (event.origin !== "http://example.org") + return; + }; + window.addEventListener("message", handleMessage); + `, + }, + { + code: ` self.onmessage = function(event) { console.log(event.data); }; @@ -325,6 +364,15 @@ describe('S2819', () => { }, { code: ` + const handleMessage = event => { + console.log(event.data); + }; + window.addEventListener("message", handleMessage); + `, + errors: [{ messageId: 'verifyOrigin' }], + }, + { + code: ` function eventHandler(event) { console.log(event.data); } From 61ebc83d8a31f74bf042770433eb2c61547c6ed7 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Tue, 4 Aug 2026 11:48:13 +0200 Subject: [PATCH 10/12] JS-2205 Leave Worker-vs-Window detection out of S2819 onmessage The `window = self` Worker shim is not distinguishable by type once the DOM lib is loaded, since `self` and `window` are both `Window & typeof globalThis`. The scope-based detection only worked while `window` was an unresolved reference, and it guarded the new `onmessage` path only, leaving the pre-existing `addEventListener("message", ...)` behaviour inconsistent with it. Report the shim on both paths instead and document the limitation. Worker and Node projects are still left alone by the `lib.dom.d.ts` check. Co-Authored-By: Claude Opus 5 (1M context) --- .../analysis/src/jsts/rules/S2819/rule.ts | 36 ++++--------------- .../src/jsts/rules/S2819/unit.test.ts | 18 +++------- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 0c38c266fc7..3561833d410 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -27,7 +27,6 @@ import { getUniqueWriteUsageOrNode, isIdentifier, isIfStatement, - functionLike, resolveFunction, } from '../helpers/ast.js'; import { isRequiredParserServices } from '../helpers/parser-services.js'; @@ -134,12 +133,14 @@ function checkOnMessageAssignment( * The name heuristic is too coarse here: `onmessage` is also a property of unrelated * transports such as WebSocket, so a `wsWindowChannel.onmessage` assignment would be * reported without this restriction. + * + * Requiring the property to come from `lib.dom.d.ts` leaves Worker and Node projects alone, + * as they compile against `lib.webworker.d.ts` or no DOM lib at all. A `window = self` shim + * in a project that does load the DOM lib is still reported: types cannot tell the two apart, + * since `self` and `window` are both `Window & typeof globalThis` there. That matches how + * `addEventListener("message", ...)` already behaves and is out of scope here. */ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { - if (isWindowAliasedToWorkerGlobal(node, context)) { - return false; - } - const receiver = getUniqueWriteUsageOrNode(context, node, true); if ( receiver.type !== 'Identifier' || @@ -156,31 +157,6 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { ); } -/** - * Detects `window = self` / `window = global` shims used by scripts meant to run in a Worker. - * - * Such a write only surfaces in `through` as long as `window` is an unresolved reference, which - * is the case for the global scope of an analyzed file. The write is only relevant when it can - * reach the receiver, hence the comparison of enclosing functions: a shim confined to another - * function does not make the receiver a Worker global. - */ -function isWindowAliasedToWorkerGlobal(node: estree.Node, context: Rule.RuleContext) { - const receiverFunction = findFirstMatchingLocalAncestor(node as TSESTree.Node, ancestor => - functionLike.has(ancestor.type), - ); - return context.sourceCode - .getScope(node) - .through.some( - reference => - reference.isWrite() && - reference.identifier.name === 'window' && - findFirstMatchingLocalAncestor(reference.identifier as TSESTree.Node, ancestor => - functionLike.has(ancestor.type), - ) === receiverFunction && - isIdentifier(reference.writeExpr ?? undefined, 'self', 'global'), - ); -} - function checkMessageListener( context: Rule.RuleContext, listenerNode: estree.Node, diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index f4b5e3fe590..fc66a19a72f 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -177,16 +177,6 @@ describe('S2819', () => { }, { code: ` - if (typeof window === 'undefined') { - window = self; - } - window.onmessage = function(event) { - console.log(event.data); - }; - `, - }, - { - code: ` type WorkerScope = { onmessage: (event: MessageEvent) => void }; function register(globalThis: WorkerScope) { globalThis.onmessage = function(event) { @@ -331,11 +321,11 @@ describe('S2819', () => { errors: [{ messageId: 'verifyOrigin' }], }, { + // a `window = self` Worker shim is not distinguishable by type when the DOM lib is + // loaded, so it is reported, consistently with `addEventListener("message", ...)` code: ` - function bootstrapWorker() { - if (typeof window === 'undefined') { - window = self; - } + if (typeof window === 'undefined') { + window = self; } window.onmessage = function(event) { console.log(event.data); From 0f36d93ae86ade0813470ee23ce7f09abf3452ec Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Tue, 4 Aug 2026 14:24:33 +0200 Subject: [PATCH 11/12] JS-2205 Restore Worker shim exclusion in S2819, resolution-independent Ruling showed the exclusion is load-bearing: its only real-world hit, ace/lib/ace/worker/worker_v2.js, aliases `window = self` at the top of a module factory and then registers `window.onmessage`. Messages there come from the parent page, so there is no origin to verify. Look the write up on the `window` variable when the configuration declares it as a global, and among the enclosing scope's unresolved references otherwise, so the check no longer depends on whether `window` resolves. Drop the enclosing function comparison: a shim mutates the global, so it disqualifies every receiver in the file. Co-Authored-By: Claude Opus 5 (1M context) --- .../analysis/src/jsts/rules/S2819/rule.ts | 36 ++++++++-- .../src/jsts/rules/S2819/unit.test.ts | 67 +++++++++++++++---- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 3561833d410..7dcb4c9621a 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -25,6 +25,7 @@ import { getTypeAsString, getTypeFromTreeNode } from '../helpers/type.js'; import { getValueOfExpression, getUniqueWriteUsageOrNode, + getVariableFromName, isIdentifier, isIfStatement, resolveFunction, @@ -133,14 +134,12 @@ function checkOnMessageAssignment( * The name heuristic is too coarse here: `onmessage` is also a property of unrelated * transports such as WebSocket, so a `wsWindowChannel.onmessage` assignment would be * reported without this restriction. - * - * Requiring the property to come from `lib.dom.d.ts` leaves Worker and Node projects alone, - * as they compile against `lib.webworker.d.ts` or no DOM lib at all. A `window = self` shim - * in a project that does load the DOM lib is still reported: types cannot tell the two apart, - * since `self` and `window` are both `Window & typeof globalThis` there. That matches how - * `addEventListener("message", ...)` already behaves and is out of scope here. */ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { + if (isWindowAliasedToWorkerGlobal(node, context)) { + return false; + } + const receiver = getUniqueWriteUsageOrNode(context, node, true); if ( receiver.type !== 'Identifier' || @@ -157,6 +156,31 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { ); } +/** + * Detects `window = self` / `window = global` shims, used by scripts meant to run in a Worker + * while written against browser idioms. Such a file mutates the global itself, so any shim in + * it disqualifies every `window` receiver, wherever the write sits. + * + * Types cannot answer this: with the DOM lib loaded, `self` and `window` are both + * `Window & typeof globalThis`, so the shim has to be found syntactically. + * + * The write is looked up on the `window` variable when the analysis configuration declares it as + * a global, and among the unresolved references of the enclosing scope otherwise. Only the second + * case is known to occur in practice; the first is covered so that the check does not depend on + * whether `window` happens to be declared. + */ +function isWindowAliasedToWorkerGlobal(node: estree.Node, context: Rule.RuleContext) { + const variable = getVariableFromName(context, 'window', node); + const writes = variable + ? variable.references.filter(reference => reference.isWrite()) + : context.sourceCode.getScope(node).through.filter(reference => reference.isWrite()); + return writes.some( + reference => + reference.identifier.name === 'window' && + isIdentifier(reference.writeExpr ?? undefined, 'self', 'global'), + ); +} + function checkMessageListener( context: Rule.RuleContext, listenerNode: estree.Node, diff --git a/packages/analysis/src/jsts/rules/S2819/unit.test.ts b/packages/analysis/src/jsts/rules/S2819/unit.test.ts index fc66a19a72f..e80fc294cb0 100644 --- a/packages/analysis/src/jsts/rules/S2819/unit.test.ts +++ b/packages/analysis/src/jsts/rules/S2819/unit.test.ts @@ -177,6 +177,60 @@ describe('S2819', () => { }, { code: ` + if (typeof window === 'undefined') { + window = self; + } + window.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + // a shim anywhere in the file disqualifies every receiver in it, since it mutates + // the global itself + code: ` + function bootstrapWorker() { + if (typeof window === 'undefined') { + window = self; + } + } + window.onmessage = function(event) { + console.log(event.data); + }; + `, + }, + { + // the shape found in the wild: shim and receiver inside a module factory + code: ` + define(function (require, exports, module) { + if (typeof window == "undefined") { + if (typeof self != "undefined") window = self; + if (typeof global != "undefined") window = global; + } + window.onmessage = function (event) { + console.log(event.data); + }; + }); + `, + }, + { + // the shim is also found when the configuration declares 'window' as a global, in + // which case the write is reached through the variable rather than through the scope + code: ` + if (typeof window === 'undefined') { + window = self; + } + window.onmessage = function(event) { + console.log(event.data); + }; + `, + languageOptions: { + sourceType: 'script', + globals: { window: 'writable', self: 'readonly' }, + }, + }, + { + code: ` type WorkerScope = { onmessage: (event: MessageEvent) => void }; function register(globalThis: WorkerScope) { globalThis.onmessage = function(event) { @@ -314,19 +368,6 @@ describe('S2819', () => { }, { code: ` - window.onmessage = function(event) { - console.log(event.data); - }; - `, - errors: [{ messageId: 'verifyOrigin' }], - }, - { - // a `window = self` Worker shim is not distinguishable by type when the DOM lib is - // loaded, so it is reported, consistently with `addEventListener("message", ...)` - code: ` - if (typeof window === 'undefined') { - window = self; - } window.onmessage = function(event) { console.log(event.data); }; From 9e41b78a292baa3e7818c891d91a0c30f185c9a9 Mon Sep 17 00:00:00 2001 From: Francois Mora Date: Tue, 4 Aug 2026 14:56:35 +0200 Subject: [PATCH 12/12] JS-2205 Order S2819 onmessage receiver checks by cost Reject on the receiver name before consulting the type checker or the scope chain, and look the Worker shim up last, so that an `onmessage` assignment on an unrelated transport no longer pays for a scope walk. Behaviour is unchanged; the predicate now also returns a plain boolean instead of `boolean | undefined`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/analysis/src/jsts/rules/S2819/rule.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/analysis/src/jsts/rules/S2819/rule.ts b/packages/analysis/src/jsts/rules/S2819/rule.ts index 7dcb4c9621a..78709dddc00 100644 --- a/packages/analysis/src/jsts/rules/S2819/rule.ts +++ b/packages/analysis/src/jsts/rules/S2819/rule.ts @@ -134,12 +134,12 @@ function checkOnMessageAssignment( * The name heuristic is too coarse here: `onmessage` is also a property of unrelated * transports such as WebSocket, so a `wsWindowChannel.onmessage` assignment would be * reported without this restriction. + * + * The conditions are ordered by cost: the receiver name rejects the vast majority of `onmessage` + * assignments without touching the type checker or the scope chain, and the Worker shim lookup + * comes last so that only a receiver that is otherwise a DOM `Window` pays for it. */ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { - if (isWindowAliasedToWorkerGlobal(node, context)) { - return false; - } - const receiver = getUniqueWriteUsageOrNode(context, node, true); if ( receiver.type !== 'Identifier' || @@ -151,9 +151,10 @@ function isWindowMessageReceiver(node: estree.Node, context: Rule.RuleContext) { const onMessage = getTypeFromTreeNode(receiver, context.sourceCode.parserServices).getProperty( ON_MESSAGE, ); - return onMessage?.declarations?.some(declaration => + const isDomWindow = onMessage?.declarations?.some(declaration => declaration.getSourceFile().fileName.endsWith('lib.dom.d.ts'), ); + return isDomWindow === true && !isWindowAliasedToWorkerGlobal(node, context); } /**