Add trusted HTML labels to multi-select lists

ⓘ Support html => 1 while preserving default escaping, text filtering and accessible checkbox names.
This commit is contained in:
Ilia Ross
2026-09-09 13:51:10 +02:00
parent c8249f9a50
commit 74c771c257
2 changed files with 49 additions and 8 deletions

View File

@@ -563,6 +563,36 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'omitting size preserves the selected description and escaped text');
}
# Trusted HTML is opt-in and filtering uses the displayed label text.
{
my $label = '<i title="a > b">Entire website &amp; files</i>';
my $options = [ { 'value' => 'root', 'label' => $label,
'suffix' => '<suffix>', 'tag' => '<tag>' } ];
my $plain = main::ui_multi_select_list('plain', [ 'root' ], $options, {});
like($plain, qr/&lt;i title&#61;/, 'labels are escaped by default');
unlike($plain, qr/<i\b/, 'default labels cannot introduce markup');
my $html = main::ui_multi_select_list('markup',
[ 'root', [ 'missing', '<b>Missing &amp;lt;path&amp;gt;</b>' ] ],
$options, { 'html' => 1 });
like($html, qr/\Q$label\E/, 'the HTML flag retains trusted label markup');
like($html, qr/aria-label="Entire website &amp; files"/,
'HTML labels retain an accessible checkbox name');
like($html, qr/<b>Missing &amp;lt;path&amp;gt;<\/b>/,
'missing selections also support trusted HTML labels');
like($html, qr/&lt;suffix&gt;/, 'HTML labels do not enable HTML suffixes');
like($html, qr/&lt;tag&gt;/, 'HTML labels do not enable HTML tags');
my @filter = $html =~ /data-ui-multi-text="([^"]*)"/g;
is_deeply([ map { decode_attr($_) } @filter ],
[ 'Entire website & files<suffix> <tag>', 'Missing &lt;path&gt;' ],
'filter text removes markup and decodes entities only once');
like($html, qr/name="markup"[^>]*value="root\nmissing"/,
'HTML labels do not change submitted values');
assert_no_handler_injection(main::ui_multi_select_list('safe', [ $xss ],
[ { 'value' => $xss, 'label' => '<b>Trusted</b>',
'suffix' => $xss, 'tag' => $xss } ], { 'html' => 1 }),
'HTML mode value and metadata');
}
# Positional calls preserve the old selected pane's labels and value order.
{
my @values = ( [ 'b', 'B (selected)', q{disabled title="Selected"} ],

View File

@@ -5714,10 +5714,11 @@ return &_ui_block('div', $select.$panels, &_ui_attrs({
=head2 ui_multi_select_list(name, &values, &options, [&opts] | [size], [add-if-missing], [disabled?], [options-title], [values-title], [width])
Returns a scrolling checkbox list with selection links, an expandable filter
and a selection count beside the mode selector or links. Like ui_multi_select,
it submits newline-joined values under name; ui-lib.js keeps them in sync.
Missing selected values are added automatically. Labels are plain text with
the options-hash API; positional calls also accept pre-escaped labels.
and a selection count beside the mode selector or links. It submits
newline-joined values under name; ui-lib.js keeps them in sync.
Missing selected values are added automatically. Labels are plain text by
default; positional calls also accept pre-escaped labels. Set html to 1 for
trusted label markup.
With no entries, it shows only empty_label, preserving the selection, mode
and children form values in hidden inputs. Nonempty lists load their assets
@@ -5745,6 +5746,8 @@ Size, add-if-missing, titles and width are ignored.
=item count - Show the selection count; defaults to on. Set to 0 to omit it.
=item html - Set to 1 to render labels as trusted HTML. Labels are escaped by default; values, suffixes and tags remain escaped.
=item placeholder - Hint text of the filter box.
=item empty_label - Plain text shown when there are no entries, defaulting to "No entries".
@@ -5958,13 +5961,20 @@ my $body = $tools ? &ui_tag('div', $tools, { 'class' => 'ui_multi_tools' }) : ''
my $rows = "";
foreach my $it (@items) {
my $val = $it->{'value'};
my $label = &html_escape($it->{'label'}, $legacy);
my $label = $opts->{'html'} ? $it->{'label'} :
&html_escape($it->{'label'}, $legacy);
# Filter on label text, excluding markup while retaining literal entities.
my $filter_label = $opts->{'html'} ?
&html_escape(&html_strip($label), 1) : $label;
$label .= &ui_tag('span', &html_escape($it->{'suffix'}),
{ 'class' => 'ui_multi_suffix' })
if (defined($it->{'suffix'}) && $it->{'suffix'} ne '');
# Checkbox renderers may place HTML outside their label element.
my $ctags = "data-ui-multi-item='1'";
$ctags .= ' aria-label="'.$filter_label.'"' if ($opts->{'html'});
my $row = &ui_checkbox($name.'_item', $val, $label,
$selected{$val} ? 1 : 0,
"data-ui-multi-item='1'",
$ctags,
$dis || $it->{'disabled'} ? 1 : 0);
if ($note && $it->{'kids'}) {
my $nattrs = { 'class' => 'ui_multi_note' };
@@ -5984,9 +5994,10 @@ foreach my $it (@items) {
$it->{'disabled'} ? 'ui_multi_disabled' : undef),
'data-ui-multi-level' => $it->{'level'} ? int($it->{'level'})
: undef,
'data-ui-multi-text' => &html_escape(join(" ",
'data-ui-multi-text' => join(" ",
grep { defined($_) && $_ ne '' }
$it->{'label'}.($it->{'suffix'} // ''), $it->{'tag'}), $legacy) }) } };
$filter_label.&html_escape($it->{'suffix'}, $legacy),
&html_escape($it->{'tag'}, $legacy)) }) } };
$attrs->{'hidden'} = undef if ($folded && $it->{'level'});
$rows .= &ui_tag('div', $row, $attrs);
}