From 024537735525fe24dfd8278779ae63ea21aba346 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Thu, 17 Jul 2025 11:14:11 -0500 Subject: [PATCH 1/2] feat(html): add serializableEvent sanitizer and tests for event serialization - Introduce `serializableEvent` in `render.ts` to sanitize DOM events, making them serializable by extracting a safe subset of properties. - Add allow-lists for event and event target properties to control what gets serialized. - Update `render.test.ts` to include comprehensive tests for `serializableEvent`, covering Event, KeyboardEvent, MouseEvent, InputEvent (with target value), and CustomEvent (with detail). - Ensure all serialized events are plain objects suitable for structured cloning. --- packages/html/src/render.ts | 65 +++++++++++++++- packages/html/test/render.test.ts | 121 ++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 2 deletions(-) diff --git a/packages/html/src/render.ts b/packages/html/src/render.ts index 62297d65a..f74632313 100644 --- a/packages/html/src/render.ts +++ b/packages/html/src/render.ts @@ -314,9 +314,70 @@ export const setNodeSanitizer = (fn: (node: VNode) => VNode | null) => { export type EventSanitizer = (event: Event) => T; -const passthroughEvent: EventSanitizer = (event: Event): Event => event; +export const passthroughEvent: EventSanitizer = (event: Event): Event => + event; + +const allowListedEventProperties = [ + "type", // general + "key", // keyboard event + "code", // keyboard event + "repeat", // keyboard event + "altKey", // keyboard & mouse event + "ctrlKey", // keyboard & mouse event + "metaKey", // keyboard & mouse event + "shiftKey", // keyboard & mouse event + "inputType", // input event + "data", // input event + "button", // mouse event + "buttons", // mouse event +]; + +const allowListedEventTargetProperties = [ + "name", // general input + "value", // general input + "checked", // checkbox + "selected", // option + "selectedIndex", // select + "selectedOptions", // select, multiple +]; -let sanitizeEvent: EventSanitizer = passthroughEvent; +/** + * Sanitize an event so it can be serialized. + * + * NOTE: This isn't yet vetted for security, it's just a coarse first pass with + * the primary objective of making events serializable. + * + * E.g. one glaring omission is that this can leak data via bubbling and we + * should sanitize quite differently if the target isn't the same as + * eventTarget. + * + * This code also doesn't make any effort to only copy properties that are + * allowed on various event types, or otherwise tailor sanitization to the event + * type. + * + * @param event - The event to sanitize. + * @returns The serializable event. + */ +export function serializableEvent(event: Event): T { + const eventObject: Record = {}; + for (const property of allowListedEventProperties) { + eventObject[property] = event[property as keyof Event]; + } + + const targetObject: Record = {}; + for (const property of allowListedEventTargetProperties) { + targetObject[property] = event.target?.[property as keyof EventTarget]; + } + if (Object.keys(targetObject).length > 0) eventObject.target = targetObject; + + if ((event as CustomEvent).detail !== undefined) { + eventObject.detail = (event as CustomEvent).detail; + } + + return JSON.parse(JSON.stringify(eventObject)) as T; +} + +let sanitizeEvent: EventSanitizer = serializableEvent; export const setEventSanitizer = (sanitize: EventSanitizer) => { sanitizeEvent = sanitize; diff --git a/packages/html/test/render.test.ts b/packages/html/test/render.test.ts index 06e7b419c..b3f28c7db 100644 --- a/packages/html/test/render.test.ts +++ b/packages/html/test/render.test.ts @@ -3,6 +3,7 @@ import { h, UI } from "@commontools/api"; import { render, renderImpl } from "../src/render.ts"; import * as assert from "./assert.ts"; import { JSDOM } from "jsdom"; +import { serializableEvent } from "../src/render.ts"; let dom: JSDOM; @@ -13,6 +14,10 @@ beforeEach(() => { globalThis.Element = dom.window.Element; globalThis.Node = dom.window.Node; globalThis.Text = dom.window.Text; + globalThis.InputEvent = dom.window.InputEvent; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; + globalThis.MouseEvent = dom.window.MouseEvent; + globalThis.CustomEvent = dom.window.CustomEvent; }); describe("render", () => { @@ -109,3 +114,119 @@ describe("renderImpl", () => { assert.equal(parent.children.length, 0); }); }); + +describe("serializableEvent", () => { + function isPlainSerializableObject(obj: any): boolean { + if (typeof obj !== "object" || obj === null) return true; // primitives are serializable + if (Array.isArray(obj)) { + return obj.every(isPlainSerializableObject); + } + if (Object.getPrototypeOf(obj) !== Object.prototype) return false; + for (const key in obj) { + if (typeof obj[key] === "function") return false; + if (!isPlainSerializableObject(obj[key])) return false; + } + return true; + } + + it("serializes a basic Event", () => { + const event = new Event("test"); + const result = serializableEvent(event); + assert.matchObject(result, { type: "test" }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + }); + + it("serializes a KeyboardEvent", () => { + const event = new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + repeat: true, + altKey: true, + ctrlKey: false, + metaKey: true, + shiftKey: false, + }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "keydown", + key: "a", + code: "KeyA", + repeat: true, + altKey: true, + ctrlKey: false, + metaKey: true, + shiftKey: false, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + }); + + it("serializes a MouseEvent", () => { + const event = new MouseEvent("click", { + button: 0, + buttons: 1, + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: true, + }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "click", + button: 0, + buttons: 1, + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: true, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + }); + + it("serializes an InputEvent with target value", () => { + const input = document.createElement("input"); + input.value = "hello"; + const event = new InputEvent("input", { + data: "h", + inputType: "insertText", + }); + Object.defineProperty(event, "target", { value: input }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "input", + data: "h", + inputType: "insertText", + target: { value: "hello" }, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + }); + + it("serializes a CustomEvent with detail", () => { + const event = new CustomEvent("custom", { detail: { foo: [42, 43] } }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "custom", + detail: { foo: [42, 43] }, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + }); +}); From 868a5fe47530fd2545779a17fd287e63b3ffba88 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Thu, 17 Jul 2025 15:02:17 -0500 Subject: [PATCH 2/2] - support multi-select - verify that not allow listed fields are actually filtered --- packages/html/src/render.ts | 11 ++- packages/html/test/render.test.ts | 125 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/html/src/render.ts b/packages/html/src/render.ts index f74632313..b52ac0fd2 100644 --- a/packages/html/src/render.ts +++ b/packages/html/src/render.ts @@ -338,7 +338,6 @@ const allowListedEventTargetProperties = [ "checked", // checkbox "selected", // option "selectedIndex", // select - "selectedOptions", // select, multiple ]; /** @@ -368,9 +367,19 @@ export function serializableEvent(event: Event): T { for (const property of allowListedEventTargetProperties) { targetObject[property] = event.target?.[property as keyof EventTarget]; } + if ( + event.target instanceof HTMLSelectElement && event.target.selectedOptions + ) { + // To support multiple selections, we create serializable option elements + targetObject.selectedOptions = Array.from(event.target.selectedOptions).map( + (option) => ({ value: option.value }), + ); + } if (Object.keys(targetObject).length > 0) eventObject.target = targetObject; if ((event as CustomEvent).detail !== undefined) { + // Could be anything, but should only come from our own custom elements. + // Step below will remove any direct references. eventObject.detail = (event as CustomEvent).detail; } diff --git a/packages/html/test/render.test.ts b/packages/html/test/render.test.ts index b3f28c7db..0cb59cc46 100644 --- a/packages/html/test/render.test.ts +++ b/packages/html/test/render.test.ts @@ -18,6 +18,7 @@ beforeEach(() => { globalThis.KeyboardEvent = dom.window.KeyboardEvent; globalThis.MouseEvent = dom.window.MouseEvent; globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.HTMLSelectElement = dom.window.HTMLSelectElement; }); describe("render", () => { @@ -138,6 +139,12 @@ describe("serializableEvent", () => { true, "Result should be a plain serializable object", ); + // Should not include non-allow-listed fields + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); }); it("serializes a KeyboardEvent", () => { @@ -166,6 +173,11 @@ describe("serializableEvent", () => { true, "Result should be a plain serializable object", ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); }); it("serializes a MouseEvent", () => { @@ -192,11 +204,17 @@ describe("serializableEvent", () => { true, "Result should be a plain serializable object", ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); }); it("serializes an InputEvent with target value", () => { const input = document.createElement("input"); input.value = "hello"; + input.id = "should-not-appear"; const event = new InputEvent("input", { data: "h", inputType: "insertText", @@ -214,6 +232,16 @@ describe("serializableEvent", () => { true, "Result should be a plain serializable object", ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); + assert.equal( + (result as any).target && "id" in (result as any).target, + false, + "Should not include id on target", + ); }); it("serializes a CustomEvent with detail", () => { @@ -228,5 +256,102 @@ describe("serializableEvent", () => { true, "Result should be a plain serializable object", ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); + }); + + it("serializes an event with HTMLSelectElement target and selectedOptions", () => { + const select = document.createElement("select"); + select.multiple = true; + select.id = "should-not-appear"; + // Create option elements + const option1 = document.createElement("option"); + option1.value = "option1"; + option1.text = "Option 1"; + const option2 = document.createElement("option"); + option2.value = "option2"; + option2.text = "Option 2"; + const option3 = document.createElement("option"); + option3.value = "option3"; + option3.text = "Option 3"; + select.appendChild(option1); + select.appendChild(option2); + select.appendChild(option3); + // Select multiple options + option1.selected = true; + option3.selected = true; + const event = new Event("change"); + Object.defineProperty(event, "target", { value: select }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "change", + target: { + selectedOptions: [ + { value: "option1" }, + { value: "option3" }, + ], + }, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); + assert.equal( + (result as any).target && "id" in (result as any).target, + false, + "Should not include id on target", + ); + }); + + it("serializes an event with single-select HTMLSelectElement target", () => { + const select = document.createElement("select"); + select.multiple = false; // single select + select.id = "should-not-appear"; + // Create option elements + const option1 = document.createElement("option"); + option1.value = "option1"; + option1.text = "Option 1"; + const option2 = document.createElement("option"); + option2.value = "option2"; + option2.text = "Option 2"; + select.appendChild(option1); + select.appendChild(option2); + // Select single option + option2.selected = true; + const event = new Event("change"); + Object.defineProperty(event, "target", { value: select }); + const result = serializableEvent(event); + assert.matchObject(result, { + type: "change", + target: { + selectedOptions: [ + { value: "option2" }, + ], + }, + }); + assert.equal( + isPlainSerializableObject(result), + true, + "Result should be a plain serializable object", + ); + assert.equal( + "timeStamp" in (result as any), + false, + "Should not include timeStamp", + ); + assert.equal( + (result as any).target && "id" in (result as any).target, + false, + "Should not include id on target", + ); }); });