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).
This commit is contained in:
Raphaël Bosi
2026-07-02 16:40:15 +02:00
committed by GitHub
parent 29e48e16ba
commit 49095dcfe0
25 changed files with 1029 additions and 384 deletions
@@ -0,0 +1,84 @@
import { type ReactElement } from 'react';
import { createCaretPreservingElement } from '../createCaretPreservingElement';
const getProps = (element: ReactElement): Record<string, unknown> =>
element.props as Record<string, unknown>;
describe('createCaretPreservingElement', () => {
it('should create an element of the requested tag', () => {
const element = createCaretPreservingElement(
'input',
{ type: 'text' },
undefined,
null,
);
expect(element.type).toBe('input');
expect(getProps(element).type).toBe('text');
});
it('should seed the initial value from value', () => {
const element = createCaretPreservingElement(
'input',
{ value: 'hello' },
undefined,
null,
);
expect(getProps(element).defaultValue).toBe('hello');
});
it('should prefer defaultValue over value for the initial value', () => {
const element = createCaretPreservingElement(
'input',
{ value: 'v', defaultValue: 'd' },
undefined,
null,
);
expect(getProps(element).defaultValue).toBe('d');
});
it('should apply forced props', () => {
const element = createCaretPreservingElement(
'textarea',
{},
{ readOnly: true },
null,
);
expect(getProps(element).readOnly).toBe(true);
});
it('should notify focus state and forward the original focus handler', () => {
const setEditableFocused = jest.fn();
const onFocus = jest.fn();
const element = createCaretPreservingElement(
'input',
{ onFocus },
undefined,
setEditableFocused,
);
const event = {} as never;
(getProps(element).onFocus as (event: unknown) => void)(event);
expect(setEditableFocused).toHaveBeenCalledWith(true);
expect(onFocus).toHaveBeenCalledWith(event);
});
it('should notify blur state', () => {
const setEditableFocused = jest.fn();
const element = createCaretPreservingElement(
'input',
{},
undefined,
setEditableFocused,
);
(getProps(element).onBlur as (event: unknown) => void)({} as never);
expect(setEditableFocused).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,95 @@
import './setupServerRenderingGlobals';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createHtmlHostWrapper } from '../createHtmlHostWrapper';
const renderWrapper = (
htmlTag: string,
props: Record<string, unknown>,
children?: string,
): string =>
renderToStaticMarkup(
createElement(createHtmlHostWrapper(htmlTag), props, children),
);
describe('createHtmlHostWrapper prop hardening', () => {
it('should drop an on* attribute whose value is not a function', () => {
const markup = renderWrapper('div', { onmouseover: 'alert(1)' });
expect(markup).not.toContain('onmouseover');
expect(markup).not.toContain('alert(1)');
});
it('should drop a normalized event attribute whose value is not a function', () => {
const markup = renderWrapper('div', { onClick: 'alert(1)' });
expect(markup).not.toContain('alert(1)');
});
it('should drop a javascript: url on href', () => {
const markup = renderWrapper('a', { href: 'javascript:alert(1)' }, 'link');
expect(markup).not.toContain('javascript:');
});
it('should drop a data: url on href', () => {
const markup = renderWrapper(
'a',
{ href: 'data:text/html,<script>alert(1)</script>' },
'link',
);
expect(markup).not.toContain('data:');
});
it('should drop a vbscript: url on href', () => {
const markup = renderWrapper('a', { href: 'vbscript:msgbox(1)' }, 'link');
expect(markup).not.toContain('vbscript:');
});
it('should drop a javascript: url on an anchor xlink:href', () => {
const markup = renderWrapper(
'a',
{ 'xlink:href': 'javascript:alert(1)' },
'link',
);
expect(markup).not.toContain('javascript:');
});
it('should drop a javascript: url on a React-style anchor xlinkHref', () => {
const markup = renderWrapper(
'a',
{ xlinkHref: 'javascript:alert(1)' },
'link',
);
expect(markup).not.toContain('javascript:');
});
it('should drop a javascript: url obfuscated with control characters', () => {
const markup = renderWrapper(
'a',
{ href: 'java\tscript:alert(1)' },
'link',
);
expect(markup).not.toContain('script:');
});
it('should keep a safe href', () => {
const markup = renderWrapper('a', { href: 'https://twenty.com' }, 'link');
expect(markup).toContain('href="https://twenty.com"');
});
it('should keep a data: image on src', () => {
const dataImageUrl = 'data:image/png;base64,iVBORw0KGgo=';
const markup = renderWrapper('img', { src: dataImageUrl });
expect(markup).toContain(dataImageUrl);
});
});
@@ -0,0 +1,63 @@
import { filterProps } from '../filterProps';
const filter = (props: Record<string, unknown>, htmlTag: string) =>
filterProps(props, htmlTag) as Record<string, unknown>;
describe('filterProps', () => {
it('should drop internal remote-dom props', () => {
const result = filter(
{ element: {}, receiver: {}, components: {}, id: 'keep' },
'div',
);
expect(result).toEqual({ id: 'keep' });
});
it('should drop undefined values', () => {
const result = filter({ title: undefined, id: 'x' }, 'div');
expect('title' in result).toBe(false);
expect(result.id).toBe('x');
});
it('should parse the style string into an object', () => {
const result = filter({ style: 'color: red' }, 'div');
expect(result.style).toEqual({ color: 'red' });
});
it('should wrap function event handlers and normalize their key', () => {
const onClick = jest.fn();
const result = filter({ onClick }, 'div');
expect(typeof result.onClick).toBe('function');
expect(result.onClick).not.toBe(onClick);
});
it('should drop event-handler props whose value is not a function', () => {
const result = filter({ onClick: 'alert(1)', onmouseover: 'x' }, 'div');
expect('onClick' in result).toBe(false);
expect('onMouseOver' in result).toBe(false);
expect('onmouseover' in result).toBe(false);
});
it('should drop a dangerous scheme on a navigation attribute', () => {
const result = filter({ href: 'javascript:alert(1)' }, 'a');
expect('href' in result).toBe(false);
});
it('should keep a dangerous scheme on a non-navigation attribute', () => {
const dataImage = 'data:image/png;base64,iVBOR';
const result = filter({ src: dataImage }, 'img');
expect(result.src).toBe(dataImage);
});
it('should keep a safe url on a navigation attribute', () => {
const result = filter({ href: 'https://twenty.com' }, 'a');
expect(result.href).toBe('https://twenty.com');
});
});
@@ -0,0 +1,33 @@
import { hasDangerousUrlScheme } from '../hasDangerousUrlScheme';
describe('hasDangerousUrlScheme', () => {
it('should detect javascript, data and vbscript schemes', () => {
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
expect(hasDangerousUrlScheme('data:text/html,<script>')).toBe(true);
expect(hasDangerousUrlScheme('vbscript:msgbox(1)')).toBe(true);
});
it('should ignore casing', () => {
expect(hasDangerousUrlScheme('JavaScript:alert(1)')).toBe(true);
});
it('should see through leading and interior control-character obfuscation', () => {
expect(hasDangerousUrlScheme(' javascript:alert(1)')).toBe(true);
expect(hasDangerousUrlScheme('java\tscript:alert(1)')).toBe(true);
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
});
it('should allow safe schemes and relative urls', () => {
expect(hasDangerousUrlScheme('https://twenty.com')).toBe(false);
expect(hasDangerousUrlScheme('mailto:hi@twenty.com')).toBe(false);
expect(hasDangerousUrlScheme('/relative/path')).toBe(false);
expect(hasDangerousUrlScheme('#anchor')).toBe(false);
expect(hasDangerousUrlScheme('blob:https://twenty.com/abc')).toBe(false);
});
it('should return false for non-string values', () => {
expect(hasDangerousUrlScheme(undefined)).toBe(false);
expect(hasDangerousUrlScheme(123)).toBe(false);
expect(hasDangerousUrlScheme({})).toBe(false);
});
});
@@ -0,0 +1,15 @@
import { isEventHandlerKey } from '../isEventHandlerKey';
describe('isEventHandlerKey', () => {
it('should match keys starting with on regardless of casing', () => {
expect(isEventHandlerKey('onClick')).toBe(true);
expect(isEventHandlerKey('onclick')).toBe(true);
expect(isEventHandlerKey('ONMOUSEOVER')).toBe(true);
});
it('should not match non-event keys', () => {
expect(isEventHandlerKey('href')).toBe(false);
expect(isEventHandlerKey('className')).toBe(false);
expect(isEventHandlerKey('data-testid')).toBe(false);
});
});
@@ -0,0 +1,31 @@
import { isNavigationUrlAttribute } from '../isNavigationUrlAttribute';
describe('isNavigationUrlAttribute', () => {
it('should treat anchor href and xlink:href as navigation attributes', () => {
expect(isNavigationUrlAttribute('a', 'href')).toBe(true);
expect(isNavigationUrlAttribute('a', 'xlink:href')).toBe(true);
expect(isNavigationUrlAttribute('a', 'xlinkHref')).toBe(true);
});
it('should ignore casing of the tag and the attribute', () => {
expect(isNavigationUrlAttribute('A', 'HREF')).toBe(true);
});
it('should treat form action and formaction as navigation attributes', () => {
expect(isNavigationUrlAttribute('area', 'href')).toBe(true);
expect(isNavigationUrlAttribute('form', 'action')).toBe(true);
expect(isNavigationUrlAttribute('button', 'formaction')).toBe(true);
expect(isNavigationUrlAttribute('input', 'formaction')).toBe(true);
});
it('should not treat resource-loading attributes as navigation', () => {
expect(isNavigationUrlAttribute('img', 'src')).toBe(false);
expect(isNavigationUrlAttribute('a', 'src')).toBe(false);
expect(isNavigationUrlAttribute('image', 'xlink:href')).toBe(false);
});
it('should not match attributes on unrelated tags', () => {
expect(isNavigationUrlAttribute('div', 'href')).toBe(false);
expect(isNavigationUrlAttribute('a', 'action')).toBe(false);
});
});
@@ -0,0 +1,26 @@
import { isTextLikeInputType } from '../isTextLikeInputType';
describe('isTextLikeInputType', () => {
it('should treat text-like input types as text-like', () => {
expect(isTextLikeInputType('text')).toBe(true);
expect(isTextLikeInputType('email')).toBe(true);
expect(isTextLikeInputType('password')).toBe(true);
expect(isTextLikeInputType('number')).toBe(true);
});
it('should treat a missing type as text-like', () => {
expect(isTextLikeInputType(undefined)).toBe(true);
expect(isTextLikeInputType('')).toBe(true);
});
it('should ignore casing', () => {
expect(isTextLikeInputType('TEXT')).toBe(true);
});
it('should not treat non-text input types as text-like', () => {
expect(isTextLikeInputType('checkbox')).toBe(false);
expect(isTextLikeInputType('radio')).toBe(false);
expect(isTextLikeInputType('file')).toBe(false);
expect(isTextLikeInputType('button')).toBe(false);
});
});
@@ -0,0 +1,35 @@
import { parseCssString } from '../parseCssString';
describe('parseCssString', () => {
it('should return the input unchanged when it is not a non-empty string', () => {
expect(parseCssString(undefined)).toBeUndefined();
expect(parseCssString('')).toBe('');
});
it('should convert kebab-case properties to camelCase', () => {
expect(parseCssString('background-color: red')).toEqual({
backgroundColor: 'red',
});
});
it('should keep custom properties as-is', () => {
expect(parseCssString('--my-var: 1px')).toEqual({ '--my-var': '1px' });
});
it('should parse multiple declarations and tolerate a trailing semicolon', () => {
expect(parseCssString('color: red; font-size: 12px;')).toEqual({
color: 'red',
fontSize: '12px',
});
});
it('should skip declarations without a colon', () => {
expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' });
});
it('should only split on the first colon so values may contain colons', () => {
expect(parseCssString('background: url(http://example.com)')).toEqual({
background: 'url(http://example.com)',
});
});
});
@@ -0,0 +1,71 @@
import { serializeEvent } from '../serializeEvent';
describe('serializeEvent', () => {
it('should return an unknown type for a non-object', () => {
expect(serializeEvent(null)).toEqual({ type: 'unknown' });
});
it('should default the type to unknown when missing', () => {
expect(serializeEvent({})).toEqual({ type: 'unknown' });
});
it('should copy only whitelisted, correctly-typed properties', () => {
const result = serializeEvent({
type: 'click',
clientX: 10,
clientY: 20,
altKey: true,
key: 'Enter',
unexpected: 'ignored',
});
expect(result).toEqual({
type: 'click',
clientX: 10,
clientY: 20,
altKey: true,
key: 'Enter',
});
});
it('should ignore properties with the wrong type', () => {
const result = serializeEvent({ type: 'wheel', deltaX: 'not-a-number' });
expect(result).toEqual({ type: 'wheel' });
});
it('should extract safe target properties', () => {
const result = serializeEvent({
type: 'change',
target: { value: 'hello', checked: true, scrollTop: 5 },
});
expect(result).toMatchObject({
type: 'change',
value: 'hello',
checked: true,
scrollTop: 5,
});
});
it('should serialize target files', () => {
const result = serializeEvent({
type: 'change',
target: {
files: {
length: 1,
0: {
name: 'a.png',
size: 1,
type: 'image/png',
lastModified: 1,
},
},
},
});
expect(result.files).toEqual([
{ name: 'a.png', size: 1, type: 'image/png', lastModified: 1 },
]);
});
});
@@ -0,0 +1,38 @@
import { serializeFileList } from '../serializeFileList';
const validFile = {
name: 'report.csv',
size: 42,
type: 'text/csv',
lastModified: 1700000000000,
};
describe('serializeFileList', () => {
it('should return undefined for a non-object', () => {
expect(serializeFileList(null)).toBeUndefined();
expect(serializeFileList('files')).toBeUndefined();
});
it('should return undefined when there is no numeric length', () => {
expect(serializeFileList({})).toBeUndefined();
});
it('should serialize only the safe metadata of each file', () => {
const result = serializeFileList({
length: 1,
0: { ...validFile, arrayBuffer: () => {} },
});
expect(result).toEqual([validFile]);
});
it('should skip entries missing required fields', () => {
const result = serializeFileList({
length: 2,
0: validFile,
1: { name: 'incomplete' },
});
expect(result).toEqual([validFile]);
});
});
@@ -0,0 +1,29 @@
import { TextDecoder, TextEncoder } from 'node:util';
class StubMessagePort {
onmessage: unknown = null;
postMessage(): void {}
addEventListener(): void {}
removeEventListener(): void {}
start(): void {}
close(): void {}
}
class StubMessageChannel {
port1 = new StubMessagePort();
port2 = new StubMessagePort();
}
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
if (typeof mutableGlobal.MessageChannel === 'undefined') {
mutableGlobal.MessageChannel = StubMessageChannel;
}
if (typeof mutableGlobal.TextEncoder === 'undefined') {
mutableGlobal.TextEncoder = TextEncoder;
}
if (typeof mutableGlobal.TextDecoder === 'undefined') {
mutableGlobal.TextDecoder = TextDecoder;
}
@@ -0,0 +1,39 @@
import { syncValuePreservingCaret } from '../syncValuePreservingCaret';
describe('syncValuePreservingCaret', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('should update the value when it differs', () => {
const input = document.createElement('input');
input.value = 'old';
syncValuePreservingCaret(input, 'new');
expect(input.value).toBe('new');
});
it('should do nothing when the value is already equal', () => {
const input = document.createElement('input');
input.value = 'same';
syncValuePreservingCaret(input, 'same');
expect(input.value).toBe('same');
});
it('should preserve the caret selection when the element is focused', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.value = 'hello world';
input.focus();
input.setSelectionRange(2, 5);
syncValuePreservingCaret(input, 'HELLO world');
expect(input.value).toBe('HELLO world');
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(5);
});
});
@@ -0,0 +1,23 @@
import { wrapEventHandler } from '../wrapEventHandler';
describe('wrapEventHandler', () => {
it('should return a function', () => {
expect(typeof wrapEventHandler(() => {})).toBe('function');
});
it('should invoke the handler with the serialized event', () => {
const handler = jest.fn();
wrapEventHandler(handler)({ type: 'click', clientX: 3 });
expect(handler).toHaveBeenCalledWith({ type: 'click', clientX: 3 });
});
it('should serialize away non-whitelisted event fields before calling the handler', () => {
const handler = jest.fn();
wrapEventHandler(handler)({ type: 'click', secret: 'leaked' });
expect(handler).toHaveBeenCalledWith({ type: 'click' });
});
});
@@ -0,0 +1,58 @@
import { isFunction, isNonEmptyString } from '@sniptt/guards';
import React from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext';
import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret';
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
export const createCaretPreservingElement = (
htmlTag: 'input' | 'textarea',
reactProps: Record<string, unknown>,
forcedProps: Record<string, unknown> | undefined,
setEditableFocused: SetEditableFocused | null,
) => {
const {
value,
defaultValue,
onFocus: forwardedOnFocus,
onBlur: forwardedOnBlur,
...rest
} = reactProps;
const initialValue = isNonEmptyString(defaultValue)
? defaultValue
: isNonEmptyString(value)
? value
: undefined;
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
setEditableFocused?.(true);
if (isFunction(forwardedOnFocus)) {
forwardedOnFocus(event);
}
};
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
setEditableFocused?.(false);
if (isFunction(forwardedOnBlur)) {
forwardedOnBlur(event);
}
};
return React.createElement(htmlTag, {
...rest,
...forcedProps,
defaultValue: initialValue,
onFocus: handleFocus,
onBlur: handleBlur,
ref: (node: CaretPreservingElement | null) => {
if (!isDefined(node)) {
return;
}
if (isNonEmptyString(value)) {
syncValuePreservingCaret(node, value);
}
},
});
};
@@ -1,33 +1,11 @@
import {
isBoolean,
isFunction,
isNonEmptyString,
isNumber,
isObject,
isString,
isUndefined,
} from '@sniptt/guards';
import React, { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { EVENT_TO_REACT } from '@/constants/EventToReact';
import { type SerializedEventData } from '@/types/SerializedEventData';
import { type SerializedFileData } from '@/types/SerializedFileData';
import {
FrontComponentInputFocusContext,
type SetEditableFocused,
} from '@/host/contexts/FrontComponentInputFocusContext';
import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext';
import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement';
import { filterProps } from '@/host/utils/filterProps';
import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType';
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
`on${domEvent}`,
reactProp,
]),
);
const VOID_ELEMENTS = new Set([
'area',
'base',
@@ -44,365 +22,8 @@ const VOID_ELEMENTS = new Set([
'wbr',
]);
const parseCssString = (
styleString: string | undefined,
): React.CSSProperties | undefined => {
if (!isNonEmptyString(styleString)) {
return styleString as React.CSSProperties | undefined;
}
const style: Record<string, string> = {};
const declarations = styleString.split(';').filter(Boolean);
for (const declaration of declarations) {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) {
continue;
}
const property = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
const isCssCustomProperty = property.startsWith('--');
const key = isCssCustomProperty
? property
: property.replace(/-([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
);
style[key] = value;
}
return style;
};
const serializeFileList = (
files: unknown,
): SerializedFileData[] | undefined => {
if (!isObject(files)) {
return undefined;
}
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
if (!isNumber(fileListLike.length)) {
return undefined;
}
const serialized: SerializedFileData[] = [];
for (let index = 0; index < fileListLike.length; index++) {
const file = fileListLike[index];
if (!isObject(file)) {
continue;
}
const fileRecord = file as Record<string, unknown>;
if (
!isString(fileRecord.name) ||
!isNumber(fileRecord.size) ||
!isString(fileRecord.type) ||
!isNumber(fileRecord.lastModified)
) {
continue;
}
serialized.push({
name: fileRecord.name,
size: fileRecord.size,
type: fileRecord.type,
lastModified: fileRecord.lastModified,
});
}
return serialized;
};
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;
};
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
return (event: unknown) => {
handler(serializeEvent(event));
};
};
const filterProps = <T extends object>(props: T): T => {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
continue;
}
if (key === 'style') {
filtered.style = parseCssString(value as string | undefined);
} else {
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (normalizedKey.startsWith('on') && isFunction(value)) {
filtered[normalizedKey] = wrapEventHandler(
value as (detail: SerializedEventData) => void,
);
} else {
filtered[normalizedKey] = value;
}
}
}
return filtered as T;
};
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
const TEXT_LIKE_INPUT_TYPES = new Set([
'text',
'search',
'url',
'tel',
'password',
'email',
'number',
'',
]);
const isTextLikeInputType = (type: unknown): boolean => {
const inputType = isString(type) ? type.toLowerCase() : '';
return TEXT_LIKE_INPUT_TYPES.has(inputType);
};
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
const syncValuePreservingCaret = (
element: CaretPreservingElement,
nextValue: string,
): void => {
if (element.value === nextValue) {
return;
}
const isFocused = document.activeElement === element;
const start = isFocused ? element.selectionStart : null;
const end = isFocused ? element.selectionEnd : null;
element.value = nextValue;
if (isFocused && isDefined(start) && isDefined(end)) {
try {
element.setSelectionRange(start, end);
} catch {}
}
};
const createCaretPreservingElement = (
htmlTag: 'input' | 'textarea',
reactProps: Record<string, unknown>,
forcedProps: Record<string, unknown> | undefined,
setEditableFocused: SetEditableFocused | null,
) => {
const {
value,
defaultValue,
onFocus: forwardedOnFocus,
onBlur: forwardedOnBlur,
...rest
} = reactProps;
const initialValue = isNonEmptyString(defaultValue)
? defaultValue
: isNonEmptyString(value)
? value
: undefined;
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
setEditableFocused?.(true);
if (isFunction(forwardedOnFocus)) {
forwardedOnFocus(event);
}
};
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
setEditableFocused?.(false);
if (isFunction(forwardedOnBlur)) {
forwardedOnBlur(event);
}
};
return React.createElement(htmlTag, {
...rest,
...forcedProps,
defaultValue: initialValue,
onFocus: handleFocus,
onBlur: handleBlur,
ref: (node: CaretPreservingElement | null) => {
if (!isDefined(node)) {
return;
}
if (isNonEmptyString(value)) {
syncValuePreservingCaret(node, value);
}
},
});
};
export const createHtmlHostWrapper = (htmlTag: string) => {
const isVoid = VOID_ELEMENTS.has(htmlTag);
const isIframe = htmlTag === 'iframe';
@@ -410,7 +31,7 @@ export const createHtmlHostWrapper = (htmlTag: string) => {
return ({ children, ...props }: WrapperProps) => {
const setEditableFocused = useContext(FrontComponentInputFocusContext);
const reactProps = filterProps(props);
const reactProps = filterProps(props, htmlTag);
const forcedProps: Record<string, unknown> | undefined = isIframe
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
@@ -0,0 +1,55 @@
import { isFunction, isUndefined } from '@sniptt/guards';
import { EVENT_TO_REACT } from '@/constants/EventToReact';
import { hasDangerousUrlScheme } from '@/host/utils/hasDangerousUrlScheme';
import { isEventHandlerKey } from '@/host/utils/isEventHandlerKey';
import { isNavigationUrlAttribute } from '@/host/utils/isNavigationUrlAttribute';
import { parseCssString } from '@/host/utils/parseCssString';
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
import { type SerializedEventData } from '@/types/SerializedEventData';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
`on${domEvent}`,
reactProp,
]),
);
export const filterProps = <T extends object>(props: T, htmlTag: string): T => {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
continue;
}
if (key === 'style') {
filtered.style = parseCssString(value as string | undefined);
continue;
}
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (isEventHandlerKey(normalizedKey)) {
if (isFunction(value)) {
filtered[normalizedKey] = wrapEventHandler(
value as (detail: SerializedEventData) => void,
);
}
continue;
}
if (
isNavigationUrlAttribute(htmlTag, normalizedKey) &&
hasDangerousUrlScheme(value)
) {
continue;
}
filtered[normalizedKey] = value;
}
return filtered as T;
};
@@ -0,0 +1,17 @@
import { isString } from '@sniptt/guards';
const DANGEROUS_URL_SCHEMES = ['javascript:', 'vbscript:', 'data:'];
export const hasDangerousUrlScheme = (value: unknown): boolean => {
if (!isString(value)) {
return false;
}
const normalizedValue = value
.replace(/[\u0000-\u0020\u007F-\u009F]/g, '')
.toLowerCase();
return DANGEROUS_URL_SCHEMES.some((scheme) =>
normalizedValue.startsWith(scheme),
);
};
@@ -0,0 +1,2 @@
export const isEventHandlerKey = (key: string): boolean =>
key.toLowerCase().startsWith('on');
@@ -0,0 +1,15 @@
const NAVIGATION_URL_ATTRIBUTES_BY_TAG: Record<string, Set<string>> = {
a: new Set(['href', 'xlinkhref']),
area: new Set(['href']),
form: new Set(['action']),
button: new Set(['formaction']),
input: new Set(['formaction']),
};
export const isNavigationUrlAttribute = (
htmlTag: string,
key: string,
): boolean =>
NAVIGATION_URL_ATTRIBUTES_BY_TAG[htmlTag.toLowerCase()]?.has(
key.toLowerCase().replace(/:/g, ''),
) ?? false;
@@ -0,0 +1,17 @@
import { isString } from '@sniptt/guards';
const TEXT_LIKE_INPUT_TYPES = new Set([
'text',
'search',
'url',
'tel',
'password',
'email',
'number',
'',
]);
export const isTextLikeInputType = (type: unknown): boolean => {
const inputType = isString(type) ? type.toLowerCase() : '';
return TEXT_LIKE_INPUT_TYPES.has(inputType);
};
@@ -0,0 +1,35 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CSSProperties } from 'react';
export const parseCssString = (
styleString: string | undefined,
): CSSProperties | undefined => {
if (!isNonEmptyString(styleString)) {
return styleString as CSSProperties | undefined;
}
const style: Record<string, string> = {};
const declarations = styleString.split(';').filter(Boolean);
for (const declaration of declarations) {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) {
continue;
}
const property = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
const isCssCustomProperty = property.startsWith('--');
const key = isCssCustomProperty
? property
: property.replace(/-([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
);
style[key] = value;
}
return style;
};
@@ -0,0 +1,171 @@
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;
};
@@ -0,0 +1,40 @@
import { isNumber, isObject, isString } from '@sniptt/guards';
import { type SerializedFileData } from '@/types/SerializedFileData';
export const serializeFileList = (
files: unknown,
): SerializedFileData[] | undefined => {
if (!isObject(files)) {
return undefined;
}
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
if (!isNumber(fileListLike.length)) {
return undefined;
}
const serialized: SerializedFileData[] = [];
for (let index = 0; index < fileListLike.length; index++) {
const file = fileListLike[index];
if (!isObject(file)) {
continue;
}
const fileRecord = file as Record<string, unknown>;
if (
!isString(fileRecord.name) ||
!isNumber(fileRecord.size) ||
!isString(fileRecord.type) ||
!isNumber(fileRecord.lastModified)
) {
continue;
}
serialized.push({
name: fileRecord.name,
size: fileRecord.size,
type: fileRecord.type,
lastModified: fileRecord.lastModified,
});
}
return serialized;
};
@@ -0,0 +1,24 @@
import { isDefined } from 'twenty-shared/utils';
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
export const syncValuePreservingCaret = (
element: CaretPreservingElement,
nextValue: string,
): void => {
if (element.value === nextValue) {
return;
}
const isFocused = document.activeElement === element;
const start = isFocused ? element.selectionStart : null;
const end = isFocused ? element.selectionEnd : null;
element.value = nextValue;
if (isFocused && isDefined(start) && isDefined(end)) {
try {
element.setSelectionRange(start, end);
} catch {}
}
};
@@ -0,0 +1,8 @@
import { serializeEvent } from '@/host/utils/serializeEvent';
import { type SerializedEventData } from '@/types/SerializedEventData';
export const wrapEventHandler =
(handler: (detail: SerializedEventData) => void) =>
(event: unknown): void => {
handler(serializeEvent(event));
};