Widen front component crossing attributes to aria-*, data-* and draggable (#22614)

Only a closed allow-list of props crossed the front-component
worker→host boundary (`id, className, style, title, tabIndex, role,
aria-label, aria-hidden, data-testid`), so arbitrary `aria-*`/`data-*`
attributes and `draggable` never reached the host DOM. That breaks
headless UI libraries (Radix, cmdk, react-aria) that drive styling/state
through those attributes.

This widens the crossing set to all `aria-*`, all `data-*`, and
`draggable`:
- `draggable` becomes an enumerated remote property (it's a DOM IDL
property React may set as a property, bypassing `setAttribute`, so it
can't ride the prefix path).
- Arbitrary `aria-*`/`data-*` are forwarded in the worker by patching
`setAttribute`/`removeAttribute` through remote-dom's attribute channel,
only for names not already synced as observed attributes.

Security: only inert `aria-*`/`data-*`/`draggable` cross, and they still
route through the host `filterProps` guards (non-function `on*` dropped,
`javascript:` URLs denied) — nothing bypasses them. The enumerated
`aria-label`/`aria-hidden`/`data-testid` keep their existing path.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22614?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-07 12:42:03 +02:00
committed by GitHub
parent 46d281f0cb
commit 3bacf7a24b
10 changed files with 247 additions and 48 deletions
@@ -0,0 +1,30 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
const DivCrossingAttributesFrontComponent = () => (
<FrontComponentCard title="div:crossing-attributes">
<div
data-testid="subject"
role="option"
aria-selected="true"
aria-activedescendant="item-2"
data-state="open"
data-count="3"
draggable={true}
>
content
</div>
<a data-testid="danger-link" href="javascript:alert(1)">
danger
</a>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-div-cross-00000000-0000-0000-0000-000000000021',
name: 'div-crossing-attributes-front-component',
description:
'Front component proving arbitrary aria-*/data-*/draggable attributes cross to the host DOM while dangerous URLs stay filtered',
component: DivCrossingAttributesFrontComponent,
});
@@ -0,0 +1,49 @@
import { type Meta } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectAttributesReflected } from '@/__stories__/shared/test-utils/matchers/expectPropertyReflected';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Grouping/Div/CrossingAttributes',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const CrossingAttributes = runFrontComponentStory({
frontComponentBundleName: 'div-crossing-attributes',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectAttributesReflected({
canvas,
attributes: {
role: 'option',
'aria-selected': 'true',
'aria-activedescendant': 'item-2',
'data-state': 'open',
'data-count': '3',
draggable: 'true',
},
});
await waitFor(() => {
const dangerLink = canvas.queryByTestId('danger-link');
expect(dangerLink).not.toBeNull();
expect(dangerLink?.getAttribute('href')).toBeNull();
});
},
});
@@ -10,4 +10,5 @@ export const HTML_COMMON_PROPERTIES: Record<string, PropertySchema> = {
'aria-label': { type: 'string', optional: true },
'aria-hidden': { type: 'boolean', optional: true },
'data-testid': { type: 'string', optional: true },
draggable: { type: 'string', optional: true },
};
@@ -60,4 +60,33 @@ describe('filterProps', () => {
expect(result.href).toBe('https://twenty.com');
});
it('should forward arbitrary aria-* and data-* attributes', () => {
const result = filter(
{
'aria-selected': 'true',
'aria-activedescendant': 'item-2',
'data-state': 'open',
'data-count': '3',
},
'div',
);
expect(result['aria-selected']).toBe('true');
expect(result['aria-activedescendant']).toBe('item-2');
expect(result['data-state']).toBe('open');
expect(result['data-count']).toBe('3');
});
it('should forward the draggable attribute', () => {
expect(filter({ draggable: 'true' }, 'div').draggable).toBe('true');
expect(filter({ draggable: true }, 'div').draggable).toBe(true);
});
it('should still drop a non-function on* handler smuggled as a data-adjacent prop', () => {
const result = filter({ onClick: 'alert(1)', 'data-state': 'open' }, 'div');
expect('onClick' in result).toBe(false);
expect(result['data-state']).toBe('open');
});
});
@@ -20,6 +20,7 @@ export type HtmlCommonProperties = {
'aria-label'?: string;
'aria-hidden'?: boolean;
'data-testid'?: string;
draggable?: string;
};
export type HtmlCommonEvents = {
click(event: RemoteEvent<SerializedEventData>): void;
@@ -121,6 +122,7 @@ const HTML_COMMON_PROPERTIES_CONFIG = {
'aria-label': { type: String },
'aria-hidden': { type: Boolean },
'data-testid': { type: String },
draggable: { type: String },
};
export const HtmlDivElement = createRemoteElement<
HtmlCommonProperties,
@@ -0,0 +1,31 @@
import { isAriaOrDataAttribute } from '../isAriaOrDataAttribute';
describe('isAriaOrDataAttribute', () => {
it('should accept arbitrary aria-* attributes', () => {
expect(isAriaOrDataAttribute('aria-selected')).toBe(true);
expect(isAriaOrDataAttribute('aria-activedescendant')).toBe(true);
});
it('should accept arbitrary data-* attributes', () => {
expect(isAriaOrDataAttribute('data-state')).toBe(true);
expect(isAriaOrDataAttribute('data-radix-collection-item')).toBe(true);
});
it('should be case-insensitive on the prefix', () => {
expect(isAriaOrDataAttribute('DATA-State')).toBe(true);
expect(isAriaOrDataAttribute('Aria-Label')).toBe(true);
});
it('should reject non aria/data attributes', () => {
expect(isAriaOrDataAttribute('draggable')).toBe(false);
expect(isAriaOrDataAttribute('class')).toBe(false);
expect(isAriaOrDataAttribute('href')).toBe(false);
expect(isAriaOrDataAttribute('onclick')).toBe(false);
expect(isAriaOrDataAttribute('id')).toBe(false);
});
it('should not match names that merely contain the prefix', () => {
expect(isAriaOrDataAttribute('metadata-id')).toBe(false);
expect(isAriaOrDataAttribute('x-aria-label')).toBe(false);
});
});
@@ -0,0 +1,9 @@
const ARIA_OR_DATA_ATTRIBUTE_PREFIXES = ['aria-', 'data-'];
export const isAriaOrDataAttribute = (attributeName: string): boolean => {
const lowercasedAttributeName = attributeName.toLowerCase();
return ARIA_OR_DATA_ATTRIBUTE_PREFIXES.some((attributePrefix) =>
lowercasedAttributeName.startsWith(attributePrefix),
);
};
@@ -0,0 +1,94 @@
import { ALLOWED_HTML_ELEMENTS } from '@/constants/AllowedHtmlElements';
import { isAriaOrDataAttribute } from '@/remote/utils/isAriaOrDataAttribute';
const ATTRIBUTE_NAME_TO_ELEMENT_PROPERTY_NAME: Record<string, string> = {
className: 'className',
class: 'className',
htmlFor: 'htmlFor',
for: 'htmlFor',
tabIndex: 'tabIndex',
tabindex: 'tabIndex',
srcDoc: 'srcDoc',
srcdoc: 'srcDoc',
};
type RemoteElementWithAttributeUpdater = Element &
Record<string, unknown> & {
updateRemoteAttribute: (attributeName: string, value?: string) => void;
};
export const patchRemoteElementAttributes = (): void => {
for (const allowedHtmlElement of ALLOWED_HTML_ELEMENTS) {
const elementConstructor = customElements.get(allowedHtmlElement.tag);
if (!elementConstructor) {
continue;
}
const attributeNamesAlreadySyncedByRemoteDom = new Set<string>(
(
elementConstructor as CustomElementConstructor & {
observedAttributes?: string[];
}
).observedAttributes ?? [],
);
const shouldForwardAttributeAcrossBoundary = (
attributeName: string,
): boolean =>
isAriaOrDataAttribute(attributeName) &&
!attributeNamesAlreadySyncedByRemoteDom.has(attributeName);
const originalSetAttribute = elementConstructor.prototype.setAttribute as (
attributeName: string,
attributeValue: string,
) => void;
elementConstructor.prototype.setAttribute = function (
this: RemoteElementWithAttributeUpdater,
attributeName: string,
attributeValue: string,
) {
const mappedElementPropertyName =
ATTRIBUTE_NAME_TO_ELEMENT_PROPERTY_NAME[attributeName];
if (mappedElementPropertyName) {
this[mappedElementPropertyName] = attributeValue;
return;
}
originalSetAttribute.call(this, attributeName, attributeValue);
if (shouldForwardAttributeAcrossBoundary(attributeName)) {
this.updateRemoteAttribute(attributeName, attributeValue);
}
};
const originalRemoveAttribute = elementConstructor.prototype
.removeAttribute as (attributeName: string) => void;
elementConstructor.prototype.removeAttribute = function (
this: RemoteElementWithAttributeUpdater,
attributeName: string,
) {
const mappedElementPropertyName =
ATTRIBUTE_NAME_TO_ELEMENT_PROPERTY_NAME[attributeName];
if (mappedElementPropertyName) {
this[mappedElementPropertyName] = undefined;
return;
}
originalRemoveAttribute.call(this, attributeName);
if (shouldForwardAttributeAcrossBoundary(attributeName)) {
this.updateRemoteAttribute(attributeName);
}
};
}
};
@@ -1,46 +0,0 @@
import { ALLOWED_HTML_ELEMENTS } from '@/constants/AllowedHtmlElements';
const ATTRIBUTE_TO_PROPERTY_MAP: Record<string, string> = {
className: 'className',
class: 'className',
htmlFor: 'htmlFor',
for: 'htmlFor',
tabIndex: 'tabIndex',
tabindex: 'tabIndex',
srcDoc: 'srcDoc',
srcdoc: 'srcDoc',
};
export const patchRemoteElementSetAttribute = (): void => {
for (const elementConfig of ALLOWED_HTML_ELEMENTS) {
const elementConstructor = customElements.get(elementConfig.tag);
if (!elementConstructor) {
continue;
}
const originalSetAttribute = elementConstructor.prototype.setAttribute as (
name: string,
value: string,
) => void;
elementConstructor.prototype.setAttribute = function (
this: Element & Record<string, unknown>,
name: string,
value: string,
) {
const propertyName = ATTRIBUTE_TO_PROPERTY_MAP[name];
if (propertyName) {
this[propertyName] = value;
return;
}
originalSetAttribute.call(this, name, value);
};
}
};
@@ -14,7 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
import { installStyleBridge } from '@/polyfills/installStyleBridge';
import { installStylePropertyOnRemoteElements } from '@/remote/utils/installStylePropertyOnRemoteElements';
import { patchRemoteElementSetAttribute } from '@/remote/utils/patchRemoteElementSetAttribute';
import { patchRemoteElementAttributes } from '@/remote/utils/patchRemoteElementAttributes';
import { installErrorEventBridge } from './utils/installErrorEventBridge';
import { type FrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { frontComponentHostCommunicationApi } from '@/constants/frontComponentHostCommunicationApi';
@@ -31,7 +31,7 @@ import {
import { setWorkerEnv } from './utils/setWorkerEnv';
installStylePropertyOnRemoteElements();
patchRemoteElementSetAttribute();
patchRemoteElementAttributes();
installErrorEventBridge();
exposeGlobals({