diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts new file mode 100644 index 0000000000..ffa4744373 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts @@ -0,0 +1,84 @@ +import { type ReactElement } from 'react'; + +import { createCaretPreservingElement } from '../createCaretPreservingElement'; + +const getProps = (element: ReactElement): Record => + element.props as Record; + +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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts new file mode 100644 index 0000000000..e5a4d370ca --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts @@ -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, + 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,' }, + '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); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/filterProps.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/filterProps.test.ts new file mode 100644 index 0000000000..8b2306f3fa --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/filterProps.test.ts @@ -0,0 +1,63 @@ +import { filterProps } from '../filterProps'; + +const filter = (props: Record, htmlTag: string) => + filterProps(props, htmlTag) as Record; + +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'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/hasDangerousUrlScheme.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/hasDangerousUrlScheme.test.ts new file mode 100644 index 0000000000..2c158f29c6 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/hasDangerousUrlScheme.test.ts @@ -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,