Skip to content

Commit 0245377

Browse files
committed
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.
1 parent c090045 commit 0245377

File tree

2 files changed

+184
-2
lines changed

2 files changed

+184
-2
lines changed

packages/html/src/render.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,70 @@ export const setNodeSanitizer = (fn: (node: VNode) => VNode | null) => {
314314

315315
export type EventSanitizer<T> = (event: Event) => T;
316316

317-
const passthroughEvent: EventSanitizer<Event> = (event: Event): Event => event;
317+
export const passthroughEvent: EventSanitizer<Event> = (event: Event): Event =>
318+
event;
319+
320+
const allowListedEventProperties = [
321+
"type", // general
322+
"key", // keyboard event
323+
"code", // keyboard event
324+
"repeat", // keyboard event
325+
"altKey", // keyboard & mouse event
326+
"ctrlKey", // keyboard & mouse event
327+
"metaKey", // keyboard & mouse event
328+
"shiftKey", // keyboard & mouse event
329+
"inputType", // input event
330+
"data", // input event
331+
"button", // mouse event
332+
"buttons", // mouse event
333+
];
334+
335+
const allowListedEventTargetProperties = [
336+
"name", // general input
337+
"value", // general input
338+
"checked", // checkbox
339+
"selected", // option
340+
"selectedIndex", // select
341+
"selectedOptions", // select, multiple
342+
];
318343

319-
let sanitizeEvent: EventSanitizer<unknown> = passthroughEvent;
344+
/**
345+
* Sanitize an event so it can be serialized.
346+
*
347+
* NOTE: This isn't yet vetted for security, it's just a coarse first pass with
348+
* the primary objective of making events serializable.
349+
*
350+
* E.g. one glaring omission is that this can leak data via bubbling and we
351+
* should sanitize quite differently if the target isn't the same as
352+
* eventTarget.
353+
*
354+
* This code also doesn't make any effort to only copy properties that are
355+
* allowed on various event types, or otherwise tailor sanitization to the event
356+
* type.
357+
*
358+
* @param event - The event to sanitize.
359+
* @returns The serializable event.
360+
*/
361+
export function serializableEvent<T>(event: Event): T {
362+
const eventObject: Record<string, any> = {};
363+
for (const property of allowListedEventProperties) {
364+
eventObject[property] = event[property as keyof Event];
365+
}
366+
367+
const targetObject: Record<string, any> = {};
368+
for (const property of allowListedEventTargetProperties) {
369+
targetObject[property] = event.target?.[property as keyof EventTarget];
370+
}
371+
if (Object.keys(targetObject).length > 0) eventObject.target = targetObject;
372+
373+
if ((event as CustomEvent).detail !== undefined) {
374+
eventObject.detail = (event as CustomEvent).detail;
375+
}
376+
377+
return JSON.parse(JSON.stringify(eventObject)) as T;
378+
}
379+
380+
let sanitizeEvent: EventSanitizer<unknown> = serializableEvent;
320381

321382
export const setEventSanitizer = (sanitize: EventSanitizer<unknown>) => {
322383
sanitizeEvent = sanitize;

packages/html/test/render.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { h, UI } from "@commontools/api";
33
import { render, renderImpl } from "../src/render.ts";
44
import * as assert from "./assert.ts";
55
import { JSDOM } from "jsdom";
6+
import { serializableEvent } from "../src/render.ts";
67

78
let dom: JSDOM;
89

@@ -13,6 +14,10 @@ beforeEach(() => {
1314
globalThis.Element = dom.window.Element;
1415
globalThis.Node = dom.window.Node;
1516
globalThis.Text = dom.window.Text;
17+
globalThis.InputEvent = dom.window.InputEvent;
18+
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
19+
globalThis.MouseEvent = dom.window.MouseEvent;
20+
globalThis.CustomEvent = dom.window.CustomEvent;
1621
});
1722

1823
describe("render", () => {
@@ -109,3 +114,119 @@ describe("renderImpl", () => {
109114
assert.equal(parent.children.length, 0);
110115
});
111116
});
117+
118+
describe("serializableEvent", () => {
119+
function isPlainSerializableObject(obj: any): boolean {
120+
if (typeof obj !== "object" || obj === null) return true; // primitives are serializable
121+
if (Array.isArray(obj)) {
122+
return obj.every(isPlainSerializableObject);
123+
}
124+
if (Object.getPrototypeOf(obj) !== Object.prototype) return false;
125+
for (const key in obj) {
126+
if (typeof obj[key] === "function") return false;
127+
if (!isPlainSerializableObject(obj[key])) return false;
128+
}
129+
return true;
130+
}
131+
132+
it("serializes a basic Event", () => {
133+
const event = new Event("test");
134+
const result = serializableEvent(event);
135+
assert.matchObject(result, { type: "test" });
136+
assert.equal(
137+
isPlainSerializableObject(result),
138+
true,
139+
"Result should be a plain serializable object",
140+
);
141+
});
142+
143+
it("serializes a KeyboardEvent", () => {
144+
const event = new KeyboardEvent("keydown", {
145+
key: "a",
146+
code: "KeyA",
147+
repeat: true,
148+
altKey: true,
149+
ctrlKey: false,
150+
metaKey: true,
151+
shiftKey: false,
152+
});
153+
const result = serializableEvent(event);
154+
assert.matchObject(result, {
155+
type: "keydown",
156+
key: "a",
157+
code: "KeyA",
158+
repeat: true,
159+
altKey: true,
160+
ctrlKey: false,
161+
metaKey: true,
162+
shiftKey: false,
163+
});
164+
assert.equal(
165+
isPlainSerializableObject(result),
166+
true,
167+
"Result should be a plain serializable object",
168+
);
169+
});
170+
171+
it("serializes a MouseEvent", () => {
172+
const event = new MouseEvent("click", {
173+
button: 0,
174+
buttons: 1,
175+
altKey: false,
176+
ctrlKey: true,
177+
metaKey: false,
178+
shiftKey: true,
179+
});
180+
const result = serializableEvent(event);
181+
assert.matchObject(result, {
182+
type: "click",
183+
button: 0,
184+
buttons: 1,
185+
altKey: false,
186+
ctrlKey: true,
187+
metaKey: false,
188+
shiftKey: true,
189+
});
190+
assert.equal(
191+
isPlainSerializableObject(result),
192+
true,
193+
"Result should be a plain serializable object",
194+
);
195+
});
196+
197+
it("serializes an InputEvent with target value", () => {
198+
const input = document.createElement("input");
199+
input.value = "hello";
200+
const event = new InputEvent("input", {
201+
data: "h",
202+
inputType: "insertText",
203+
});
204+
Object.defineProperty(event, "target", { value: input });
205+
const result = serializableEvent(event);
206+
assert.matchObject(result, {
207+
type: "input",
208+
data: "h",
209+
inputType: "insertText",
210+
target: { value: "hello" },
211+
});
212+
assert.equal(
213+
isPlainSerializableObject(result),
214+
true,
215+
"Result should be a plain serializable object",
216+
);
217+
});
218+
219+
it("serializes a CustomEvent with detail", () => {
220+
const event = new CustomEvent("custom", { detail: { foo: [42, 43] } });
221+
const result = serializableEvent(event);
222+
assert.matchObject(result, {
223+
type: "custom",
224+
detail: { foo: [42, 43] },
225+
});
226+
assert.equal(
227+
isPlainSerializableObject(result),
228+
true,
229+
"Result should be a plain serializable object",
230+
);
231+
});
232+
});

0 commit comments

Comments
 (0)