Files
twenty/packages/twenty-front-component-renderer/src/host/utils/serializeEvent.ts
T
Raphaël Bosi 49095dcfe0 Drop unsafe props from front component elements (#22458)
## What

Front components are third-party React components rendered into the host
page through a restricted element allow-list. `filterProps` (where their
props become real DOM attributes) used to forward unrecognized values
as-is, which left two ways to run script in the host origin:

- an `on*` attribute with a string value, which React renders as an
inline event handler;
- a dangerous-scheme URL (`javascript:`, `data:`, `vbscript:`) on a
link, which executes on navigation.

## Change

`filterProps` now drops both:

- `on*` props are kept only when the value is a real function (still
wrapped as before); any non-function `on*` is dropped.
- `javascript:` / `data:` / `vbscript:` URLs are dropped, but only on
**navigation targets** (`<a>`/`<area>` `href`/`xlink:href`, `<form>`
`action`, `<button>`/`<input>` `formaction`), after normalizing away
control-character obfuscation (e.g. `java\tscript:`). Resource-loading
attributes are left alone, so `<img src="data:image/...">` keeps
working.

Well-behaved components are unaffected: function handlers are still
wrapped and normal URLs pass through. Host-side only, no worker or SDK
changes.

## Scope: the actual behavior change is small

The diff looks large, but most of it is **not** a behavior change.
`createHtmlHostWrapper.ts` (~460 lines) was split into
one-export-per-file utils (`filterProps`, `serializeEvent`,
`parseCssString`, `hasDangerousUrlScheme`, etc.), each with its own unit
test, leaving `createHtmlHostWrapper.ts` as a thin orchestrator. Those
helpers were **moved unchanged** — the only real logic change is the
`filterProps` hardening described above. The pre-existing render-based
integration test passes untouched, which confirms the split is
behavior-neutral; the rest of the new files are extractions plus added
test coverage.

## Why these schemes, and only on navigation targets

Per MDN, `javascript:` (and `data:`) URLs are dangerous specifically
where a URL is a *navigation target*, not where it is a *resource
location* (like an image `src`) — which is exactly how the check is
scoped:

- [`javascript:` URLs
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript)
- [`data:` URLs
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data)
- [URI schemes overview
(MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes)

This is a prerequisite for later work that widens the raw-attribute
surface (innerHTML rendering).
2026-07-02 14:40:15 +00:00

172 lines
4.7 KiB
TypeScript

import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { serializeFileList } from '@/host/utils/serializeFileList';
import { type SerializedEventData } from '@/types/SerializedEventData';
export const serializeEvent = (event: unknown): SerializedEventData => {
if (!isObject(event)) {
return { type: 'unknown' };
}
const domEvent = event as Record<string, unknown>;
const serialized: SerializedEventData = {
type: isString(domEvent.type) ? domEvent.type : 'unknown',
};
if (isBoolean(domEvent.altKey)) {
serialized.altKey = domEvent.altKey;
}
if (isBoolean(domEvent.ctrlKey)) {
serialized.ctrlKey = domEvent.ctrlKey;
}
if (isBoolean(domEvent.metaKey)) {
serialized.metaKey = domEvent.metaKey;
}
if (isBoolean(domEvent.shiftKey)) {
serialized.shiftKey = domEvent.shiftKey;
}
if (isNumber(domEvent.clientX)) {
serialized.clientX = domEvent.clientX;
}
if (isNumber(domEvent.clientY)) {
serialized.clientY = domEvent.clientY;
}
if (isNumber(domEvent.x)) {
serialized.x = domEvent.x;
}
if (isNumber(domEvent.y)) {
serialized.y = domEvent.y;
}
if (isNumber(domEvent.pageX)) {
serialized.pageX = domEvent.pageX;
}
if (isNumber(domEvent.pageY)) {
serialized.pageY = domEvent.pageY;
}
if (isNumber(domEvent.screenX)) {
serialized.screenX = domEvent.screenX;
}
if (isNumber(domEvent.screenY)) {
serialized.screenY = domEvent.screenY;
}
if (isNumber(domEvent.offsetX)) {
serialized.offsetX = domEvent.offsetX;
}
if (isNumber(domEvent.offsetY)) {
serialized.offsetY = domEvent.offsetY;
}
if (isNumber(domEvent.movementX)) {
serialized.movementX = domEvent.movementX;
}
if (isNumber(domEvent.movementY)) {
serialized.movementY = domEvent.movementY;
}
if (isNumber(domEvent.button)) {
serialized.button = domEvent.button;
}
if (isNumber(domEvent.buttons)) {
serialized.buttons = domEvent.buttons;
}
if (isNumber(domEvent.pointerId)) {
serialized.pointerId = domEvent.pointerId;
}
if (isString(domEvent.pointerType)) {
serialized.pointerType = domEvent.pointerType;
}
if (isNumber(domEvent.pressure)) {
serialized.pressure = domEvent.pressure;
}
if (isNumber(domEvent.tangentialPressure)) {
serialized.tangentialPressure = domEvent.tangentialPressure;
}
if (isNumber(domEvent.tiltX)) {
serialized.tiltX = domEvent.tiltX;
}
if (isNumber(domEvent.tiltY)) {
serialized.tiltY = domEvent.tiltY;
}
if (isNumber(domEvent.twist)) {
serialized.twist = domEvent.twist;
}
if (isNumber(domEvent.width)) {
serialized.width = domEvent.width;
}
if (isNumber(domEvent.height)) {
serialized.height = domEvent.height;
}
if (isBoolean(domEvent.isPrimary)) {
serialized.isPrimary = domEvent.isPrimary;
}
if (isString(domEvent.key)) {
serialized.key = domEvent.key;
}
if (isString(domEvent.code)) {
serialized.code = domEvent.code;
}
if (isBoolean(domEvent.repeat)) {
serialized.repeat = domEvent.repeat;
}
if (isNumber(domEvent.deltaX)) {
serialized.deltaX = domEvent.deltaX;
}
if (isNumber(domEvent.deltaY)) {
serialized.deltaY = domEvent.deltaY;
}
if (isNumber(domEvent.deltaZ)) {
serialized.deltaZ = domEvent.deltaZ;
}
if (isNumber(domEvent.deltaMode)) {
serialized.deltaMode = domEvent.deltaMode;
}
const target = domEvent.target;
if (isObject(target)) {
const targetRecord = target as Record<string, unknown>;
if (isString(targetRecord.value)) {
serialized.value = targetRecord.value;
}
if (isBoolean(targetRecord.checked)) {
serialized.checked = targetRecord.checked;
}
if (isNumber(targetRecord.scrollTop)) {
serialized.scrollTop = targetRecord.scrollTop;
}
if (isNumber(targetRecord.scrollLeft)) {
serialized.scrollLeft = targetRecord.scrollLeft;
}
if (isNumber(targetRecord.currentTime)) {
serialized.currentTime = targetRecord.currentTime;
}
if (isNumber(targetRecord.duration)) {
serialized.duration = targetRecord.duration;
}
if (isBoolean(targetRecord.paused)) {
serialized.paused = targetRecord.paused;
}
if (isBoolean(targetRecord.ended)) {
serialized.ended = targetRecord.ended;
}
if (isNumber(targetRecord.volume)) {
serialized.volume = targetRecord.volume;
}
if (isBoolean(targetRecord.muted)) {
serialized.muted = targetRecord.muted;
}
if (isNumber(targetRecord.playbackRate)) {
serialized.playbackRate = targetRecord.playbackRate;
}
const files = serializeFileList(targetRecord.files);
if (isDefined(files)) {
serialized.files = files;
}
}
return serialized;
};