Skip to content

Commit 72a64d9

Browse files
authored
IBX-9793: Fixed XSS issues in several places (see commit description)
* IBX-9793: Fix XSS in TagViewSelect * IBX-9869: Fix XSS in search in the dropdown * IBX-9901: Fix XSS in autocomplete * IBX-9888: Fix XSS in Dashboard - Products by category * IBX-9872: Use ibexa-taggify in ezkeyword * IBX-9872: fix ezobjectrelationlist * Taggify: handle input blur
1 parent e7195f8 commit 72a64d9

File tree

10 files changed

+133
-138
lines changed

10 files changed

+133
-138
lines changed

src/bundle/Resources/public/js/scripts/admin.search.autocomplete.content.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
return total;
2121
}
2222

23-
return index === 0 ? parentLocation.name : `${total} / ${parentLocation.name}`;
23+
const parentLocationNameHTMLEscaped = escapeHTML(parentLocation.name);
24+
25+
return index === 0 ? parentLocationNameHTMLEscaped : `${total} / ${parentLocationNameHTMLEscaped}`;
2426
}, '');
2527
const breadcrumbsClass = !breadcrumb ? 'ibexa-global-search__autocomplete-item-breadcrumbs--empty' : '';
2628
const autocompleteItemTemplate = autocompleteContentTemplateNode.dataset.templateItem;

src/bundle/Resources/public/js/scripts/core/doughnut.chart.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
(function (global, doc, ibexa, ChartDataLabels) {
2+
const { escapeHTML } = ibexa.helpers.text;
3+
const { dangerouslyInsertAdjacentHTML } = ibexa.helpers.dom;
24
const IBEXA_WHITE = '#fff';
35
const IBEXA_COLOR_BASE_DARK = '#878b90';
46
const dataLabelsMap = new Map();
@@ -71,13 +73,14 @@
7173
data.legend.forEach((legendItem, index) => {
7274
dataLabelsMap.set(index, true);
7375

76+
const legendItemHtmlEscaped = escapeHTML(legendItem);
7477
const container = doc.createElement('div');
7578
const renderedItemTemplate = itemTemplate
7679
.replace('{{ checked_color }}', data.backgroundColor[index])
7780
.replace('{{ dataset_index }}', index)
78-
.replace('{{ label }}', legendItem);
81+
.replace('{{ label }}', legendItemHtmlEscaped);
7982

80-
container.insertAdjacentHTML('beforeend', renderedItemTemplate);
83+
dangerouslyInsertAdjacentHTML(container, 'beforeend', renderedItemTemplate);
8184

8285
const checkboxNode = container.querySelector('.ibexa-chart-legend__item-wrapper');
8386

src/bundle/Resources/public/js/scripts/core/dropdown.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
(function (global, doc, ibexa, bootstrap, Translator) {
2+
const { escapeHTML, escapeHTMLAttribute } = ibexa.helpers.text;
3+
const { dangerouslySetInnerHTML, dangerouslyInsertAdjacentHTML } = ibexa.helpers.dom;
4+
25
const EVENT_VALUE_CHANGED = 'change';
36
const RESTRICTED_AREA_ITEMS_CONTAINER = 190;
47
const MINIMUM_LETTERS_TO_FILTER = 3;
@@ -104,10 +107,10 @@
104107
createSelectedItem(value, label, icon) {
105108
const container = doc.createElement('div');
106109
const selectedItemRendered = this.selectedItemTemplate
107-
.replaceAll('{{ value }}', ibexa.helpers.text.escapeHTMLAttribute(value))
110+
.replaceAll('{{ value }}', escapeHTMLAttribute(value))
108111
.replaceAll('{{ label }}', label);
109112

110-
container.insertAdjacentHTML('beforeend', selectedItemRendered);
113+
dangerouslyInsertAdjacentHTML(container, 'beforeend', selectedItemRendered);
111114

112115
const selectedItemNode = container.querySelector('.ibexa-dropdown__selected-item');
113116
const removeSelectionBtn = selectedItemNode.querySelector('.ibexa-dropdown__remove-selection');
@@ -341,7 +344,7 @@
341344
const separator = this.itemsListContainer.querySelector('.ibexa-dropdown__separator');
342345
const emptyMessage = Translator.trans(
343346
/*@Desc("No results found for "%phrase%"")*/ 'dropdown.no_results',
344-
{ phrase: searchedTerm },
347+
{ phrase: escapeHTML(searchedTerm) },
345348
'ibexa_dropdown',
346349
);
347350
let hideSeparator = true;
@@ -378,7 +381,10 @@
378381

379382
this.itemsListContainer.toggleAttribute('hidden', !anyItemVisible);
380383
this.itemsListFilterEmptyContainer.toggleAttribute('hidden', anyItemVisible);
381-
this.itemsListFilterEmptyContainer.querySelector('.ibexa-dropdown__items-list-filter-empty-message').innerHTML = emptyMessage;
384+
385+
const emptyMessageNode = this.itemsListFilterEmptyContainer.querySelector('.ibexa-dropdown__items-list-filter-empty-message');
386+
387+
dangerouslySetInnerHTML(emptyMessageNode, emptyMessage);
382388
}
383389

384390
itemsPopoverContent() {
@@ -434,9 +440,7 @@
434440

435441
createOption(value, label) {
436442
const container = doc.createElement('div');
437-
const itemRendered = this.itemTemplate
438-
.replaceAll('{{ value }}', ibexa.helpers.text.escapeHTMLAttribute(value))
439-
.replaceAll('{{ label }}', label);
443+
const itemRendered = this.itemTemplate.replaceAll('{{ value }}', escapeHTMLAttribute(value)).replaceAll('{{ label }}', label);
440444

441445
container.insertAdjacentHTML('beforeend', itemRendered);
442446

src/bundle/Resources/public/js/scripts/core/tag.view.select.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
(function (global, doc, ibexa) {
2+
const { escapeHTML } = ibexa.helpers.text;
3+
const { dangerouslyAppend } = ibexa.helpers.dom;
4+
25
class TagViewSelect {
36
constructor(config) {
47
this.inputSelector = config.inputSelector || 'input';
@@ -74,14 +77,14 @@
7477

7578
items.forEach((item) => {
7679
const { id, name } = item;
77-
const itemTemplate = this.selectedItemTemplate.replace('{{ id }}', id).replaceAll('{{ name }}', name);
80+
const itemTemplate = this.selectedItemTemplate.replace('{{ id }}', id).replaceAll('{{ name }}', escapeHTML(name));
7881
const range = doc.createRange();
7982
const itemHtmlWidget = range.createContextualFragment(itemTemplate);
8083
const deleteButton = itemHtmlWidget.querySelector('.ibexa-tag-view-select__selected-item-tag-remove-btn');
8184

8285
deleteButton.toggleAttribute('disabled', false);
8386
deleteButton.addEventListener('click', () => this.removeItem(String(id)), false);
84-
this.listContainer.append(itemHtmlWidget);
87+
dangerouslyAppend(this.listContainer, itemHtmlWidget);
8588
});
8689

8790
this.inputField.dispatchEvent(new Event('change'));

src/bundle/Resources/public/js/scripts/core/taggify.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
(function (global, doc, ibexa) {
2+
const { escapeHTML, escapeHTMLAttribute } = ibexa.helpers.text;
3+
const { dangerouslyInsertAdjacentHTML } = ibexa.helpers.dom;
4+
25
class Taggify {
36
constructor(config) {
47
this.container = config.container;
@@ -10,6 +13,7 @@
1013

1114
this.attachEventsToTag = this.attachEventsToTag.bind(this);
1215
this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
16+
this.handleInputBlur = this.handleInputBlur.bind(this);
1317
this.addElementAttributes = this.addElementAttributes.bind(this);
1418
}
1519

@@ -19,6 +23,14 @@
1923
return this.acceptKeys.includes(key);
2024
}
2125

26+
removeAcceptKey(name) {
27+
if (this.acceptKeys.includes(name.at(-1))) {
28+
return name.slice(0, -1);
29+
}
30+
31+
return name;
32+
}
33+
2234
addElementAttributes(element, attrs) {
2335
const getValue = (value) => (typeof value === 'object' ? JSON.stringify(value) : value);
2436

@@ -36,23 +48,37 @@
3648
}
3749

3850
addTag(name, value, attrs = {}, tooltipAttrs = {}) {
51+
if (!value || this.tags.has(value)) {
52+
return;
53+
}
54+
3955
const tagTemplate = this.listNode.dataset.template;
40-
const renderedTemplate = tagTemplate.replace('{{ name }}', name).replace('{{ value }}', value);
56+
const nameHtmlEscaped = escapeHTML(name);
57+
const valueHtmlAttributeEscaped = escapeHTMLAttribute(value);
58+
const renderedTemplate = tagTemplate.replace('{{ name }}', nameHtmlEscaped).replace('{{ value }}', valueHtmlAttributeEscaped);
4159
const div = doc.createElement('div');
4260

43-
div.insertAdjacentHTML('beforeend', renderedTemplate);
61+
dangerouslyInsertAdjacentHTML(div, 'beforeend', renderedTemplate);
4462

4563
const tag = div.querySelector('.ibexa-taggify__list-tag');
4664
const tagNameNode = tag.querySelector('.ibexa-taggify__list-tag-name');
4765

48-
this.addElementAttributes(tag, attrs);
66+
this.addElementAttributes(tag, { ...attrs, dataset: { ...attrs.dataset, value } });
4967
this.addElementAttributes(tagNameNode, tooltipAttrs);
5068
this.attachEventsToTag(tag, value);
5169
this.listNode.insertBefore(tag, this.inputNode);
5270
this.tags.add(value);
5371
this.afterTagsUpdate();
5472
}
5573

74+
removeTagWithValue(value) {
75+
const tag = this.listNode.querySelector(`.ibexa-taggify__list-tag[data-value="${value}"]`);
76+
77+
if (tag) {
78+
this.removeTag(tag, value);
79+
}
80+
}
81+
5682
removeTag(tag, value) {
5783
this.tags.delete(value);
5884

@@ -61,26 +87,49 @@
6187
this.afterTagsUpdate();
6288
}
6389

90+
removeAllTags() {
91+
this.tags.forEach((tag) => {
92+
const tagNode = this.listNode.querySelector(`.ibexa-taggify__list-tag[data-value="${tag}"]`);
93+
94+
if (tagNode) {
95+
tagNode.remove();
96+
}
97+
});
98+
99+
this.tags.clear();
100+
this.afterTagsUpdate();
101+
}
102+
64103
attachEventsToTag(tag, value) {
65104
const removeBtn = tag.querySelector('.ibexa-taggify__btn--remove');
66105

67106
removeBtn.addEventListener('click', () => this.removeTag(tag, value), false);
68107
}
69108

109+
handleInputBlur() {
110+
const inputValue = this.inputNode.value.trim();
111+
112+
this.addTag(inputValue, inputValue);
113+
this.inputNode.value = '';
114+
}
115+
70116
handleInputKeyUp(event) {
71117
if (this.tagsPattern && !this.tagsPattern.test(this.inputNode.value)) {
72118
return;
73119
}
74120

75-
if (this.isAcceptKeyPressed(event.key) && this.inputNode.value && !this.tags.has(this.inputNode.value)) {
76-
this.addTag(this.inputNode.value, this.inputNode.value);
121+
if (this.isAcceptKeyPressed(event.key)) {
122+
const nameWithoutAcceptKey = this.removeAcceptKey(this.inputNode.value);
123+
124+
this.addTag(nameWithoutAcceptKey, nameWithoutAcceptKey);
77125

78126
this.inputNode.value = '';
79127
}
80128
}
81129

82130
init() {
83131
this.inputNode.addEventListener('keyup', this.handleInputKeyUp, false);
132+
this.inputNode.addEventListener('blur', this.handleInputBlur, false);
84133
}
85134
}
86135

src/bundle/Resources/public/js/scripts/fieldType/ezkeyword.js

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,8 @@
1-
import { parse as parseTooltips } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/tooltips.helper';
2-
31
(function (global, doc, ibexa) {
42
const SELECTOR_FIELD = '.ibexa-field-edit--ezkeyword';
5-
const SELECTOR_TAGGIFY = '.ibexa-data-source__taggify';
3+
const SELECTOR_TAGGIFY_CONTAINER = '.ibexa-data-source__taggify';
4+
const SELECTOR_TAGGIFY = '.ibexa-data-source__taggify .ibexa-taggify';
65
const SELECTOR_ERROR_NODE = '.ibexa-form-error';
7-
const CLASS_TAGGIFY_FOCUS = 'ibexa-data-source__taggify--focused';
8-
const ENTER_KEY_CODE = 13;
9-
const COMMA_KEY_CODE = 188;
10-
const tagsObserverConfig = {
11-
childList: true,
12-
subtree: true,
13-
};
14-
const addTooltipToTag = (mutationList) => {
15-
mutationList.forEach((mutation) => {
16-
if (mutation.target.classList.contains('taggify__tags')) {
17-
mutation.addedNodes.forEach((addedNode) => {
18-
const labelElement = addedNode.querySelector('.taggify__tag-label');
19-
20-
labelElement.title = labelElement.innerText;
21-
});
22-
23-
parseTooltips(mutation.target);
24-
}
25-
});
26-
};
276

287
class EzKeywordValidator extends ibexa.BaseFieldValidator {
298
/**
@@ -65,18 +44,16 @@ import { parse as parseTooltips } from '@ibexa-admin-ui/src/bundle/Resources/pub
6544
};
6645

6746
doc.querySelectorAll(SELECTOR_FIELD).forEach((field) => {
68-
const taggifyContainer = field.querySelector(SELECTOR_TAGGIFY);
69-
const tagsObserver = new MutationObserver(addTooltipToTag);
70-
71-
tagsObserver.observe(taggifyContainer, tagsObserverConfig);
47+
const taggifyContainer = field.querySelector(SELECTOR_TAGGIFY_CONTAINER);
48+
const ibexaTaggifyNode = taggifyContainer.querySelector('.ibexa-taggify');
7249

7350
const validator = new EzKeywordValidator({
7451
classInvalid: 'is-invalid',
7552
fieldSelector: SELECTOR_FIELD,
7653
eventsMap: [
7754
{
7855
isValueValidator: false,
79-
selector: `${SELECTOR_FIELD} .taggify__input`,
56+
selector: `${SELECTOR_FIELD} .ibexa-taggify__input`,
8057
eventName: 'blur',
8158
callback: 'validateKeywords',
8259
errorNodeSelectors: [SELECTOR_ERROR_NODE],
@@ -91,37 +68,38 @@ import { parse as parseTooltips } from '@ibexa-admin-ui/src/bundle/Resources/pub
9168
},
9269
],
9370
});
94-
const taggify = new global.Taggify({
95-
containerNode: taggifyContainer,
96-
displayLabel: false,
97-
displayInputValues: false,
98-
hotKeys: [ENTER_KEY_CODE, COMMA_KEY_CODE],
99-
});
71+
10072
const keywordInput = field.querySelector('.ibexa-data-source__input-wrapper .ibexa-data-source__input.form-control');
73+
class EzKeywordTaggify extends ibexa.core.Taggify {
74+
afterTagsUpdate() {
75+
const tags = [...this.tags];
76+
77+
keywordInput.value = tags.join();
78+
keywordInput.dispatchEvent(new Event('change'));
79+
}
80+
}
81+
const taggify = new EzKeywordTaggify({
82+
container: ibexaTaggifyNode,
83+
acceptKeys: ['Enter', ','],
84+
});
10185
const updateKeywords = updateValue.bind(this, keywordInput);
102-
const addFocusState = () => taggifyContainer.classList.add(CLASS_TAGGIFY_FOCUS);
103-
const removeFocusState = () => taggifyContainer.classList.remove(CLASS_TAGGIFY_FOCUS);
10486
const taggifyInput = taggifyContainer.querySelector('.taggify__input');
10587

10688
if (keywordInput.required) {
10789
taggifyInput.setAttribute('required', true);
10890
}
10991

11092
validator.init();
93+
taggify.init();
11194

11295
if (keywordInput.value.length) {
113-
taggify.updateTags(
114-
keywordInput.value.split(',').map((item) => ({
115-
id: Math.floor((1 + Math.random()) * 0x10000).toString(16),
116-
label: item,
117-
})),
118-
);
96+
keywordInput.value.split(',').forEach((tag) => {
97+
taggify.addTag(tag, tag);
98+
});
11999
}
120100

121101
taggifyContainer.addEventListener('tagsCreated', updateKeywords, false);
122102
taggifyContainer.addEventListener('tagRemoved', updateKeywords, false);
123-
taggifyInput.addEventListener('focus', addFocusState, false);
124-
taggifyInput.addEventListener('blur', removeFocusState, false);
125103

126104
ibexa.addConfig('fieldTypeValidators', [validator], true);
127105
});

src/bundle/Resources/public/js/scripts/fieldType/ezobjectrelationlist.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
(function (global, doc, ibexa, React, ReactDOM, Translator) {
2+
const { dangerouslyInsertAdjacentHTML } = ibexa.helpers.dom;
3+
const { escapeHTML, escapeHTMLAttribute } = ibexa.helpers.text;
4+
const { formatShortDateTime } = ibexa.helpers.timezone;
25
const CLASS_FIELD_SINGLE = 'ibexa-field-edit--ezobjectrelation';
36
const SELECTOR_FIELD_MULTIPLE = '.ibexa-field-edit--ezobjectrelationlist';
47
const SELECTOR_FIELD_SINGLE = '.ibexa-field-edit--ezobjectrelation';
@@ -72,9 +75,8 @@
7275
const closeUDW = () => udwRoot.unmount();
7376
const renderRows = (items) => {
7477
items.forEach((item, index) => {
75-
relationsContainer.insertAdjacentHTML('beforeend', renderRow(item, index));
78+
dangerouslyInsertAdjacentHTML(relationsContainer, 'beforeend', renderRow(item, index));
7679

77-
const { escapeHTML } = ibexa.helpers.text;
7880
const itemNodes = relationsContainer.querySelectorAll('.ibexa-relations__item');
7981
const itemNode = itemNodes[itemNodes.length - 1];
8082
const contentId = escapeHTML(item.ContentInfo.Content._id);
@@ -168,17 +170,16 @@
168170
};
169171
const excludeDuplicatedItems = (items) => items.filter((item) => !selectedItems.includes(item.ContentInfo.Content._id));
170172
const renderRow = (item, index) => {
171-
const { escapeHTML } = ibexa.helpers.text;
172-
const { formatShortDateTime } = ibexa.helpers.timezone;
173173
const contentTypeName = ibexa.helpers.contentType.getContentTypeName(item.ContentInfo.Content.ContentTypeInfo.identifier);
174-
const contentName = escapeHTML(item.ContentInfo.Content.TranslatedName);
175-
const contentId = escapeHTML(item.ContentInfo.Content._id);
174+
const contentTypeNameHtmlAttributeEscaped = escapeHTMLAttribute(contentTypeName);
175+
const contentNameHtmlEscaped = escapeHTML(item.ContentInfo.Content.TranslatedName);
176+
const contentIdHtmlEscaped = escapeHTML(item.ContentInfo.Content._id);
176177
const { rowTemplate } = relationsWrapper.dataset;
177178

178179
return rowTemplate
179-
.replace('{{ content_id }}', contentId)
180-
.replace('{{ content_name }}', contentName)
181-
.replace('{{ content_type_name }}', contentTypeName)
180+
.replace('{{ content_id }}', contentIdHtmlEscaped)
181+
.replace('{{ content_name }}', contentNameHtmlEscaped)
182+
.replace('{{ content_type_name }}', contentTypeNameHtmlAttributeEscaped)
182183
.replace('{{ published_date }}', formatShortDateTime(item.ContentInfo.Content.publishedDate))
183184
.replace('{{ order }}', selectedItems.length + index + 1);
184185
};

0 commit comments

Comments
 (0)