From da3ed28dc60c33cc9bf5fcfb86c030ae966e9eb5 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Fri, 8 May 2026 14:41:08 +0200 Subject: [PATCH 1/3] fix(list-item): reject non-custom-element names in primaryComponent The `primaryComponent` slot rendered any string passed in `name` as a JSX tag. With consumer-supplied props spread on the result, a malicious or untrusted `name` like `iframe`, `script`, or `a` could be rendered with arbitrary attributes. Reject names that don't contain a hyphen, matching the HTML custom element spec. Custom elements always contain a hyphen, so legitimate consumers are unaffected. --- src/components/list-item/list-item.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx index 0a23d67979..741b276b9a 100644 --- a/src/components/list-item/list-item.tsx +++ b/src/components/list-item/list-item.tsx @@ -249,6 +249,15 @@ export class ListItemComponent implements ListItem { return; } + // Defense-in-depth: only render valid custom-element names + // (must contain a hyphen per the HTML spec). This blocks the + // slot from being used to instantiate built-in tags like + // `iframe`, `script`, or `a` with arbitrary attributes if a + // consumer ever pipes untrusted data into `primaryComponent`. + if (!primary.name.includes('-')) { + return; + } + const PrimaryComponent: any = primary.name; const props = primary.props || {}; From 5c34619405c5830f5a9529fc23ba5503ae9514be Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Fri, 8 May 2026 14:41:32 +0200 Subject: [PATCH 2/3] refactor(list-item): extract renderListComponent into a shared util Move the `ListComponent` rendering logic, including the hyphen check introduced in the previous commit, out of `list-item.tsx` and into `src/util/render-list-component.tsx`. This allows other components that render the same data shape (such as `limel-select`) to share the exact same rendering and validation behavior, so future changes to how `ListComponent` is interpreted only need to land in one place. No behavior change. --- src/components/list-item/list-item.tsx | 20 ++----------- src/util/render-list-component.tsx | 40 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 src/util/render-list-component.tsx diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx index 741b276b9a..8d65582eb8 100644 --- a/src/components/list-item/list-item.tsx +++ b/src/components/list-item/list-item.tsx @@ -2,6 +2,7 @@ import { Component, Host, Prop, h } from '@stencil/core'; import { getIconName } from '../icon/get-icon-props'; import type { IconSize } from '../icon/icon.types'; import { createRandomString } from '../../util/random-string'; +import { renderListComponent } from '../../util/render-list-component'; import { ListItem } from './list-item.types'; import { MenuItem } from '../menu/menu.types'; import { ListSeparator } from '../../global/shared-types/separator.types'; @@ -244,24 +245,7 @@ export class ListItemComponent implements ListItem { }; private renderPrimaryComponent = () => { - const primary = this.primaryComponent; - if (!primary?.name) { - return; - } - - // Defense-in-depth: only render valid custom-element names - // (must contain a hyphen per the HTML spec). This blocks the - // slot from being used to instantiate built-in tags like - // `iframe`, `script`, or `a` with arbitrary attributes if a - // consumer ever pipes untrusted data into `primaryComponent`. - if (!primary.name.includes('-')) { - return; - } - - const PrimaryComponent: any = primary.name; - const props = primary.props || {}; - - return ; + return renderListComponent(this.primaryComponent); }; private renderImage = () => { diff --git a/src/util/render-list-component.tsx b/src/util/render-list-component.tsx new file mode 100644 index 0000000000..84ec5a9b53 --- /dev/null +++ b/src/util/render-list-component.tsx @@ -0,0 +1,40 @@ +import { h, VNode } from '@stencil/core'; +import { ListComponent } from '../components/list-item/list-item.types'; + +/** + * Renders a `ListComponent` as a JSX element. + * + * Returns `undefined` when no component is provided, when its `name` is + * empty, or when its `name` is not a valid custom-element tag (i.e. does + * not contain a hyphen). The hyphen requirement matches the HTML custom + * element spec and prevents the slot from being used to render built-in + * tags like `iframe`, `script`, or `a` with arbitrary attributes. + * + * If `extraClass` is provided, it is merged with any consumer-supplied + * `class` from `component.props` and applied directly to the rendered + * element. This lets host components style or position the rendered + * element without wrapping it (which would otherwise block consumer + * styles like `order` from reaching the surrounding flex layout). + * @param component the `ListComponent` to render, or `undefined` + * @param extraClass an optional class to apply to the rendered element, + * merged with any consumer-supplied `class` in `props` + */ +export function renderListComponent( + component: ListComponent | undefined, + extraClass?: string +): VNode | undefined { + if (!component?.name) { + return; + } + + if (!component.name.includes('-')) { + return; + } + + const Tag: any = component.name; + const props = component.props || {}; + const mergedClass = + [extraClass, props.class].filter(Boolean).join(' ') || undefined; + + return ; +} From d185bc6dec723426a67519040f354eeab9e70383 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Fri, 8 May 2026 15:08:04 +0200 Subject: [PATCH 3/3] feat(select): enable showing a custom component along with options Adds a `primaryComponent` field to `Option` so consumers can render a custom component along with the icon and text of an option. The component is rendered both in the dropdown list (forwarded to `limel-list-item`) and in the trigger area when the option is selected. When any option carries a `primaryComponent`, the native mobile dropdown is bypassed in favor of the menu-based dropdown, since native `