From 1b8610e4d89c672a4009db06ff2518909d58bf7e Mon Sep 17 00:00:00 2001 From: Saq Imtiaz Date: Thu, 9 Jul 2026 13:50:12 +0200 Subject: [PATCH] Fixes issue with copying cross window event properties (#9905) * fix: issue with copying cross window event properties * docs: added changenote --- core/modules/utils/utils.js | 34 ++- .../test-utils-copyObjectProperiesSafe.js | 240 ++++++++++++++++++ .../tiddlers/releasenotes/5.4.1/#9905.tid | 13 + 3 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 editions/test/tiddlers/tests/test-utils-copyObjectProperiesSafe.js create mode 100644 editions/tw5.com/tiddlers/releasenotes/5.4.1/#9905.tid diff --git a/core/modules/utils/utils.js b/core/modules/utils/utils.js index ac09e731f8..876bdec6a1 100644 --- a/core/modules/utils/utils.js +++ b/core/modules/utils/utils.js @@ -243,8 +243,28 @@ exports.slowInSlowOut = function(t) { }; exports.copyObjectPropertiesSafe = function(object) { - const seen = new Set(), - isDOMElement = (value) => value instanceof Node || value instanceof Window; + const seen = new Set(); + + function isDOMElement(value) { + if(!value || typeof value !== "object") { + return false; + } + + // Cross-realm DOM nodes + if(typeof value.nodeType === "number" && + typeof value.nodeName === "string") { + return true; + } + + // Cross-realm Window objects + if(value.window === value && + value.document && + value.location) { + return true; + } + + return false; + } function safeCopy(obj) { // skip circular references @@ -255,10 +275,6 @@ exports.copyObjectPropertiesSafe = function(object) { if(typeof obj !== "object" || obj === null) { return obj; } - // skip DOM elements - if(isDOMElement(obj)) { - return undefined; - } // copy arrays, preserving positions if(Array.isArray(obj)) { return obj.map((item) => { @@ -266,7 +282,11 @@ exports.copyObjectPropertiesSafe = function(object) { return value === undefined ? null : value; }); } - + // skip DOM elements + if(isDOMElement(obj)) { + return undefined; + } + seen.add(obj); const copy = {}; let key, diff --git a/editions/test/tiddlers/tests/test-utils-copyObjectProperiesSafe.js b/editions/test/tiddlers/tests/test-utils-copyObjectProperiesSafe.js new file mode 100644 index 0000000000..b6565bc889 --- /dev/null +++ b/editions/test/tiddlers/tests/test-utils-copyObjectProperiesSafe.js @@ -0,0 +1,240 @@ +/*\ +title: test-utils-copyObjectPropertiesSafe.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests $tw.utils.copyObjectPropertiesSafe, the root cause of #9869. + +$eventcatcher serialises DOM events via JSON.stringify(copyObjectPropertiesSafe(event)). + +The original bug was caused by instanceof Node/Window being realm-specific. +When an event originated from a different browser window, DOM objects from that +window were not detected and JSON.stringify() could throw "Illegal invocation". + +The implementation must: +- skip DOM nodes and Window objects from other realms +- preserve normal event data +- preserve CustomEvent.detail payloads +- continue copying enumerable properties from non-DOM objects +- preserve arrays and break circular references + +The tests use substitutes for foreign DOM objects because headless Node does not +provide a second browser realm. These objects model the important characteristics: +DOM nodes have nodeType/nodeName, and Window objects have window/self/document. +\*/ + +"use strict"; + +describe("copyObjectPropertiesSafe (#9869)", function() { + + var cops = $tw.utils.copyObjectPropertiesSafe; + + // Simulates a DOM node from another realm. + // The important characteristics are: + // - nodeType/nodeName identify it as a DOM node + // - circular parentNode references resemble real DOM trees + function fakeElement(tagName,extra) { + var node = $tw.utils.extend({ + nodeType: 1, + nodeName: tagName, + tagName: tagName + },extra || {}); + + node.parentNode = node; + return node; + } + + // Simulates a Window object from another realm. + function fakeWindow() { + var win = { + document: {}, + location: {} + }; + + win.window = win; + win.self = win; + + return win; + } + + // An event carrying foreign DOM nodes and a Window. + function fakeEvent(type,target,extra) { + return $tw.utils.extend({ + type: type, + target: target, + currentTarget: target, + view: fakeWindow(), + detail: 0, + isTrusted: true + },extra || {}); + } + + + it("does not throw serialising any event type from a secondary window", function() { + var events = [ + fakeEvent("focusin",fakeElement("INPUT")), + fakeEvent("change",fakeElement("SELECT")), + fakeEvent("click",fakeElement("BUTTON"),{ + button: 0, + clientX: 5, + clientY: 9, + relatedTarget: null + }), + fakeEvent("mouseover",fakeElement("DIV"),{ + relatedTarget: fakeElement("SPAN") + }) + ]; + + events.forEach(function(event) { + expect(function() { + JSON.stringify(cops(event)); + }).not.toThrow(); + }); + }); + + + it("drops DOM nodes and Window but keeps serialisable event data", function() { + var event = fakeEvent("click",fakeElement("BUTTON"),{ + button: 0, + clientX: 5, + clientY: 9 + }); + + var result = JSON.parse(JSON.stringify(cops(event))); + + expect(result.target).toBeUndefined(); + expect(result.currentTarget).toBeUndefined(); + expect(result.view).toBeUndefined(); + + expect(result.type).toBe("click"); + expect(result.detail).toBe(0); + expect(result.isTrusted).toBe(true); + expect(result.button).toBe(0); + expect(result.clientX).toBe(5); + expect(result.clientY).toBe(9); + }); + + + it("preserves nested objects, arrays and circular reference handling", function() { + var event = fakeEvent("custom",fakeElement("DIV"),{ + detail: { + nested: { + a: 1, + b: [2,3] + } + }, + path: [ + fakeElement("DIV"), + fakeWindow(), + 42 + ] + }); + + event.self = event; + + var result = JSON.parse(JSON.stringify(cops(event))); + + expect(result.detail).toEqual({ + nested: { + a: 1, + b: [2,3] + } + }); + + expect(result.path).toEqual([ + null, + null, + 42 + ]); + + expect(result.self).toBeUndefined(); + }); + + + it("preserves CustomEvent.detail objects including custom object instances", function() { + function DetailObject() { + this.name = "example"; + this.values = [1,2,3]; + } + + var event = fakeEvent("custom",fakeElement("DIV"),{ + detail: new DetailObject() + }); + + var result = JSON.parse(JSON.stringify(cops(event))); + + expect(result.detail).toEqual({ + name: "example", + values: [1,2,3] + }); + }); + + + it("preserves CustomEvent.detail objects while dropping DOM objects inside them", function() { + var event = fakeEvent("custom",fakeElement("DIV"),{ + detail: { + value: 123, + nested: { + target: fakeElement("SPAN"), + kept: "yes" + }, + items: [ + fakeElement("BUTTON"), + 42 + ] + } + }); + + var result = JSON.parse(JSON.stringify(cops(event))); + + expect(result.detail.value).toBe(123); + + expect(result.detail.nested.target).toBeUndefined(); + expect(result.detail.nested.kept).toBe("yes"); + + expect(result.detail.items).toEqual([ + null, + 42 + ]); + }); + + + it("accepts primitive, array and object arguments", function() { + var nullProto = Object.create(null); + nullProto.kept = true; + + expect(cops(42)).toBe(42); + expect(cops(null)).toBe(null); + + expect(cops([ + 1, + "two", + {three: 3} + ])).toEqual([ + 1, + "two", + {three: 3} + ]); + + expect(cops({ + a: 1, + b: { + c: 2 + } + })).toEqual({ + a: 1, + b: { + c: 2 + } + }); + + expect(JSON.parse(JSON.stringify(cops({ + nullProto: nullProto + })))).toEqual({ + nullProto: { + kept: true + } + }); + }); + +}); \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/releasenotes/5.4.1/#9905.tid b/editions/tw5.com/tiddlers/releasenotes/5.4.1/#9905.tid new file mode 100644 index 0000000000..6e98e88cea --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.4.1/#9905.tid @@ -0,0 +1,13 @@ +title: $:/changenotes/5.4.1/#9905 +created: 20260709142414000 +modified: 20260709142414000 +description: Fix errors in eventcatcher while serializing event properties +release: 5.4.1 +tags: $:/tags/ChangeNote +change-type: bugfix +change-category: internal +github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9905 +github-contributors: saqimtiaz +type: text/vnd.tiddlywiki + +Fixes an issue in utils.copyObjectPropertiesSafe which did not correctly handle DOM objects from other windows resulting in an error. \ No newline at end of file