diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 26a5d91f7..b2b720c96 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -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 = 'Entire website & files'; + my $options = [ { 'value' => 'root', 'label' => $label, + 'suffix' => '', 'tag' => '' } ]; + my $plain = main::ui_multi_select_list('plain', [ 'root' ], $options, {}); + like($plain, qr/<i title=/, 'labels are escaped by default'); + unlike($plain, qr/Missing &lt;path&gt;' ] ], + $options, { 'html' => 1 }); + like($html, qr/\Q$label\E/, 'the HTML flag retains trusted label markup'); + like($html, qr/aria-label="Entire website & files"/, + 'HTML labels retain an accessible checkbox name'); + like($html, qr/Missing &lt;path&gt;<\/b>/, + 'missing selections also support trusted HTML labels'); + like($html, qr/<suffix>/, 'HTML labels do not enable HTML suffixes'); + like($html, qr/<tag>/, 'HTML labels do not enable HTML tags'); + my @filter = $html =~ /data-ui-multi-text="([^"]*)"/g; + is_deeply([ map { decode_attr($_) } @filter ], + [ 'Entire website & files ', 'Missing <path>' ], + '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' => 'Trusted', + '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"} ], diff --git a/ui-lib.pl b/ui-lib.pl index 78d448770..8bf50cf39 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -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); }