Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions packages/html/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,79 @@ export const setNodeSanitizer = (fn: (node: VNode) => VNode | null) => {

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

const passthroughEvent: EventSanitizer<Event> = (event: Event): Event => event;
export const passthroughEvent: EventSanitizer<Event> = (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
];

let sanitizeEvent: EventSanitizer<unknown> = 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<T>(event: Event): T {
const eventObject: Record<string, any> = {};
for (const property of allowListedEventProperties) {
eventObject[property] = event[property as keyof Event];
}

const targetObject: Record<string, any> = {};
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detail could contain anything, but the serialization cycle should at least sever e.g. element references

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added comment. my understanding is that only our own custom elements can trigger those, right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, or at least no CustomEvents fired from default browser interaction

}

return JSON.parse(JSON.stringify(eventObject)) as T;
}

let sanitizeEvent: EventSanitizer<unknown> = serializableEvent;

export const setEventSanitizer = (sanitize: EventSanitizer<unknown>) => {
sanitizeEvent = sanitize;
Expand Down
246 changes: 246 additions & 0 deletions packages/html/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,6 +14,11 @@ 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;
globalThis.HTMLSelectElement = dom.window.HTMLSelectElement;
});

describe("render", () => {
Expand Down Expand Up @@ -109,3 +115,243 @@ 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",
);
// Should not include non-allow-listed fields
assert.equal(
"timeStamp" in (result as any),
false,
"Should not include timeStamp",
);
});

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",
);
assert.equal(
"timeStamp" in (result as any),
false,
"Should not include timeStamp",
);
});

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",
);
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",
});
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",
);
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", () => {
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",
);
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",
);
});
});