From 25aee716f1bfccdf4764b70497fdb7ae1d5c7554 Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Wed, 22 Jul 2026 11:13:28 -0400 Subject: [PATCH 1/2] fix: unescaped mustache rendering for object values in FormSelectList --- src/components/FormSelectList.vue | 12 +++++- tests/unit/FormSelectList.spec.js | 65 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/components/FormSelectList.vue b/src/components/FormSelectList.vue index 678dd873..5bad4187 100644 --- a/src/components/FormSelectList.vue +++ b/src/components/FormSelectList.vue @@ -579,6 +579,13 @@ export default { }); return resultList; }, + renderMustacheUnescaped(template, data) { + const { escape } = Mustache; + Mustache.escape = (t) => t; // Do not escape mustache content + const result = Mustache.render(template, data); + Mustache.escape = escape; // Reset mustache to original escape function + return result; + }, addObjectContentProp(parsedOption) { if (!(parsedOption instanceof Object)) { return parsedOption; @@ -586,6 +593,7 @@ export default { const suffix = this.attributeParent(this.options.value); let contentProperty = this.options.value; let ariaLabelProperty = this.options.ariaLabel || this.options.value; + const renderMustacheUnescaped = this.renderMustacheUnescaped.bind(this); if (contentProperty.indexOf("{{") === -1) { contentProperty = `{{ ${contentProperty} }}`; @@ -604,7 +612,7 @@ export default { } else { data = this; } - return Mustache.render(contentProperty, data); + return renderMustacheUnescaped(contentProperty, data); } }); } @@ -619,7 +627,7 @@ export default { } else { data = this; } - return Mustache.render(ariaLabelProperty, data); + return renderMustacheUnescaped(ariaLabelProperty, data); } }); } diff --git a/tests/unit/FormSelectList.spec.js b/tests/unit/FormSelectList.spec.js index 46950209..2c131bd4 100644 --- a/tests/unit/FormSelectList.spec.js +++ b/tests/unit/FormSelectList.spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils' +import Mustache from 'mustache'; import FormSelectList from '../../src/components/FormSelectList.vue'; describe('FormSelectList', () => { @@ -244,4 +245,68 @@ describe('FormSelectList', () => { expect(wrapper.find('.invalid-feedback').exists()).toBe(false); expect(wrapper.find('select').classes('is-invalid')).toBe(false); }); + + describe('Mustache label escaping for object values', () => { + const specialLabel = 'Hello & World '; + const dataConnectorOptions = { + renderAs: 'dropdown', + allowMultiSelect: true, + dataSource: 'dataConnector', + valueTypeReturned: 'object', + key: 'id', + value: 'name', + optionAriaLabel: 'name' + }; + + beforeEach(() => { + window.ProcessMaker = { user: {} }; + window.validatorLanguageSet = true; + }); + + const mountDataConnector = () => + shallowMount(FormSelectList, { + mocks: { $t }, + propsData: { + options: dataConnectorOptions + }, + computed: { + mode: () => 'preview' + } + }); + + it('does not HTML-escape special characters in transformOptions', () => { + const wrapper = mountDataConnector(); + const list = [{ id: 1, name: specialLabel }]; + + const transformed = wrapper.vm.transformOptions(list); + + expect(transformed[0].__content__).toBe(specialLabel); + expect(transformed[0].__ariaLabel__).toBe(specialLabel); + expect(transformed[0].__content__).not.toContain('&'); + expect(transformed[0].__content__).not.toContain('<'); + }); + + it('does not HTML-escape special characters in addObjectContentProp (reload path)', () => { + const wrapper = mountDataConnector(); + const selectedObject = { id: 1, name: specialLabel }; + + const withContent = wrapper.vm.addObjectContentProp(selectedObject); + + expect(withContent.__content__).toBe(specialLabel); + expect(withContent.__ariaLabel__).toBe(specialLabel); + expect(withContent.__content__).not.toContain('&'); + expect(withContent.__content__).not.toContain('<'); + }); + + it('restores Mustache.escape after renderMustacheUnescaped', () => { + const wrapper = mountDataConnector(); + const originalEscape = Mustache.escape; + + const rendered = wrapper.vm.renderMustacheUnescaped('{{name}}', { name: specialLabel }); + + expect(rendered).toBe(specialLabel); + expect(Mustache.escape).toBe(originalEscape); + expect(Mustache.escape('&')).toBe('&'); + }); + }); }); From 4617acc4c9cc7761cee53afb336f2364985d5b0b Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Wed, 22 Jul 2026 11:36:48 -0400 Subject: [PATCH 2/2] fix: use triple mustache instead of disabling escaping --- src/components/FormSelectList.vue | 46 ++++++++++++++++--------------- tests/unit/FormSelectList.spec.js | 29 +++++++++++++++++-- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/components/FormSelectList.vue b/src/components/FormSelectList.vue index 5bad4187..b0f0c5d9 100644 --- a/src/components/FormSelectList.vue +++ b/src/components/FormSelectList.vue @@ -528,33 +528,28 @@ export default { list.forEach((item) => { // if the content has a mustache expression - const { escape } = Mustache; - Mustache.escape = (t) => t; // Do not escape mustache content - let parsedOption = {}; if (this.options.key) { const itemValue = this.options.key.indexOf("{{") >= 0 - ? Mustache.render(this.options.key, item) - : Mustache.render(`{{${this.options.key || "value"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.key), item) + : Mustache.render(`{{{${this.options.key || "value"}}}}`, item); parsedOption[this.optionsKey] = itemValue; } const itemContent = this.options.value.indexOf("{{") >= 0 - ? Mustache.render(this.options.value, item) - : Mustache.render(`{{${this.options.value || "content"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.value), item) + : Mustache.render(`{{{${this.options.value || "content"}}}}`, item); // Modified ariaLabel handling let itemAriaLabel = itemContent; if (this.options.optionAriaLabel) { itemAriaLabel = this.options.optionAriaLabel.indexOf("{{") >= 0 - ? Mustache.render(this.options.optionAriaLabel, item) - : Mustache.render(`{{${this.options.optionAriaLabel || "ariaLabel"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.optionAriaLabel), item) + : Mustache.render(`{{{${this.options.optionAriaLabel || "ariaLabel"}}}}`, item); } - Mustache.escape = escape; // Reset mustache to original escape function - parsedOption[this.optionsValue] = itemContent; parsedOption[this.optionsAriaLabel] = itemAriaLabel; @@ -579,12 +574,16 @@ export default { }); return resultList; }, - renderMustacheUnescaped(template, data) { - const { escape } = Mustache; - Mustache.escape = (t) => t; // Do not escape mustache content - const result = Mustache.render(template, data); - Mustache.escape = escape; // Reset mustache to original escape function - return result; + toUnescapedMustache(template) { + // Convert {{ var }} to {{{ var }}} so special characters are not HTML-escaped. + // Leave sections, inverted sections, partials, comments, and already-unescaped tags alone. + return template.replace(/\{\{(?!\{)([^}]+)\}\}(?!\})/g, (match, content) => { + const trimmed = content.trim(); + if (!trimmed || /^[#/^!>&]/.test(trimmed)) { + return match; + } + return `{{{${content}}}}`; + }); }, addObjectContentProp(parsedOption) { if (!(parsedOption instanceof Object)) { @@ -593,13 +592,16 @@ export default { const suffix = this.attributeParent(this.options.value); let contentProperty = this.options.value; let ariaLabelProperty = this.options.ariaLabel || this.options.value; - const renderMustacheUnescaped = this.renderMustacheUnescaped.bind(this); if (contentProperty.indexOf("{{") === -1) { - contentProperty = `{{ ${contentProperty} }}`; + contentProperty = `{{{ ${contentProperty} }}}`; + } else { + contentProperty = this.toUnescapedMustache(contentProperty); } if (ariaLabelProperty.indexOf("{{") === -1) { - ariaLabelProperty = `{{ ${ariaLabelProperty} }}`; + ariaLabelProperty = `{{{ ${ariaLabelProperty} }}}`; + } else { + ariaLabelProperty = this.toUnescapedMustache(ariaLabelProperty); } if (!parsedOption.hasOwnProperty(this.optionsValue)) { @@ -612,7 +614,7 @@ export default { } else { data = this; } - return renderMustacheUnescaped(contentProperty, data); + return Mustache.render(contentProperty, data); } }); } @@ -627,7 +629,7 @@ export default { } else { data = this; } - return renderMustacheUnescaped(ariaLabelProperty, data); + return Mustache.render(ariaLabelProperty, data); } }); } diff --git a/tests/unit/FormSelectList.spec.js b/tests/unit/FormSelectList.spec.js index 2c131bd4..0f27eb68 100644 --- a/tests/unit/FormSelectList.spec.js +++ b/tests/unit/FormSelectList.spec.js @@ -298,15 +298,40 @@ describe('FormSelectList', () => { expect(withContent.__content__).not.toContain('<'); }); - it('restores Mustache.escape after renderMustacheUnescaped', () => { + it('converts double-brace variables to triple-brace without touching Mustache.escape', () => { const wrapper = mountDataConnector(); const originalEscape = Mustache.escape; - const rendered = wrapper.vm.renderMustacheUnescaped('{{name}}', { name: specialLabel }); + expect(wrapper.vm.toUnescapedMustache('{{name}}')).toBe('{{{name}}}'); + expect(wrapper.vm.toUnescapedMustache('{{name}} - {{code}}')).toBe('{{{name}}} - {{{code}}}'); + expect(wrapper.vm.toUnescapedMustache('{{#items}}{{name}}{{/items}}')).toBe('{{#items}}{{{name}}}{{/items}}'); + expect(wrapper.vm.toUnescapedMustache('{{{name}}}')).toBe('{{{name}}}'); + const rendered = Mustache.render(wrapper.vm.toUnescapedMustache('{{name}}'), { name: specialLabel }); expect(rendered).toBe(specialLabel); expect(Mustache.escape).toBe(originalEscape); expect(Mustache.escape('&')).toBe('&'); }); + + it('does not HTML-escape special characters for mustache templates on reload', () => { + const wrapper = shallowMount(FormSelectList, { + mocks: { $t }, + propsData: { + options: { + ...dataConnectorOptions, + value: '{{name}}' + } + }, + computed: { + mode: () => 'preview' + } + }); + const selectedObject = { id: 1, name: specialLabel }; + + const withContent = wrapper.vm.addObjectContentProp(selectedObject); + + expect(withContent.__content__).toBe(specialLabel); + expect(withContent.__ariaLabel__).toBe(specialLabel); + }); }); });