Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions cypress/e2e/secure-text-warning.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/** cSpell:ignore vscomp securetext */

// Tests for S1: when enableSecureText is disabled (default), option text is rendered as raw HTML.
// The plugin should log a single (per page) console warning so the XSS trade-off is discoverable,
// without changing the default (kept off for large-dataset performance).

describe('Security: enableSecureText one-time warning (S1)', () => {
const mount = (win: Window, id: string, extra: Record<string, unknown> = {}) => {
const doc = win.document;
const existing = doc.getElementById(id);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = id;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }], ...extra });
return $ele;
};

it('warns once (not per instance) when enableSecureText is disabled', () => {
cy.visit('get-started');
cy.window().then((win) => {
// reset the one-time flag so the assertion is deterministic regardless of demo dropdowns
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warn');

mount(win, 'vs-s1-a');
mount(win, 'vs-s1-b'); // second instance must NOT warn again
});

cy.get('@warn').its('callCount').should('eq', 1);
cy.get('@warn').its('firstCall.args.0').should('include', 'enableSecureText');
});

it('does not warn when enableSecureText is enabled', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warnEnabled');

mount(win, 'vs-s1-c', { enableSecureText: true });
});

cy.get('@warnEnabled').should('not.be.called');
});

it('does not warn when showSecureTextWarning is false (explicit opt-out)', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warnOptedOut');

mount(win, 'vs-s1-d', { showSecureTextWarning: false });
});

cy.get('@warnOptedOut').should('not.be.called');
});

it('warns even when constructed with empty options (options may be loaded later)', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - static flag
win.VirtualSelect.secureTextWarningShown = false;
cy.spy(win.console, 'warn').as('warnEmpty');

// The warning fires on configuration alone, so a select initialised with no options
// (then populated later via setOptions / server search) is still flagged.
const doc = win.document;
const existing = doc.getElementById('vs-s1-empty');
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = 'vs-s1-empty';
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [] });
});

cy.get('@warnEmpty').its('callCount').should('eq', 1);
cy.get('@warnEmpty').its('firstCall.args.0').should('include', 'enableSecureText');
});
});
2 changes: 1 addition & 1 deletion dist-archive/virtual-select-1.2.0.min.js

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion dist/virtual-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ const keyDownMethodMapping = {
const valueLessProps = ['autofocus', 'disabled', 'multiple', 'required'];
const nativeProps = ['autofocus', 'class', 'disabled', 'id', 'multiple', 'name', 'placeholder', 'required'];
let attrPropsMapping;
const dataProps = ['additionalClasses', 'additionalDropboxClasses', 'additionalDropboxContainerClasses', 'additionalToggleButtonClasses', 'aliasKey', 'allOptionsSelectedText', 'allowNewOption', 'alwaysShowSelectedOptionsCount', 'alwaysShowSelectedOptionsLabel', 'ariaLabelledby', 'ariaLabelText', 'ariaLabelClearButtonText', 'ariaLabelTagClearButtonText', 'ariaLabelSearchClearButtonText', 'autoSelectFirstOption', 'clearButtonText', 'descriptionKey', 'disableAllOptionsSelectedText', 'disableOptionGroupCheckbox', 'disableSelectAll', 'disableValidation', 'dropboxWidth', 'dropboxWrapper', 'emptyValue', 'enableSecureText', 'focusSelectedOptionOnOpen', 'hasOptionDescription', 'hideClearButton', 'hideValueTooltipOnSelectAll', 'keepAlwaysOpen', 'labelKey', 'markSearchResults', 'maxValues', 'maxWidth', 'minValues', 'moreText', 'noOfDisplayValues', 'noOptionsText', 'noSearchResultsText', 'optionHeight', 'optionSelectedText', 'optionsCount', 'optionsSelectedText', 'popupDropboxBreakpoint', 'popupPosition', 'position', 'search', 'searchByStartsWith', 'searchDelay', 'searchFormLabel', 'searchGroup', 'searchNormalize', 'searchPlaceholderText', 'selectAllOnlyVisible', 'selectAllText', 'setValueAsArray', 'showDropboxAsPopup', 'showOptionsOnlyOnSearch', 'showSelectedOptionsFirst', 'showValueAsTags', 'silentInitialValueSet', 'textDirection', 'tooltipAlignment', 'tooltipFontSize', 'tooltipMaxWidth', 'updatePositionThrottle', 'useGroupValue', 'valueKey', 'zIndex'];
const dataProps = ['additionalClasses', 'additionalDropboxClasses', 'additionalDropboxContainerClasses', 'additionalToggleButtonClasses', 'aliasKey', 'allOptionsSelectedText', 'allowNewOption', 'alwaysShowSelectedOptionsCount', 'alwaysShowSelectedOptionsLabel', 'ariaLabelledby', 'ariaLabelText', 'ariaLabelClearButtonText', 'ariaLabelTagClearButtonText', 'ariaLabelSearchClearButtonText', 'autoSelectFirstOption', 'clearButtonText', 'descriptionKey', 'disableAllOptionsSelectedText', 'disableOptionGroupCheckbox', 'disableSelectAll', 'disableValidation', 'dropboxWidth', 'dropboxWrapper', 'emptyValue', 'enableSecureText', 'focusSelectedOptionOnOpen', 'hasOptionDescription', 'hideClearButton', 'hideValueTooltipOnSelectAll', 'keepAlwaysOpen', 'labelKey', 'markSearchResults', 'maxValues', 'maxWidth', 'minValues', 'moreText', 'noOfDisplayValues', 'noOptionsText', 'noSearchResultsText', 'optionHeight', 'optionSelectedText', 'optionsCount', 'optionsSelectedText', 'popupDropboxBreakpoint', 'popupPosition', 'position', 'search', 'searchByStartsWith', 'searchDelay', 'searchFormLabel', 'searchGroup', 'searchNormalize', 'searchPlaceholderText', 'selectAllOnlyVisible', 'selectAllText', 'setValueAsArray', 'showDropboxAsPopup', 'showOptionsOnlyOnSearch', 'showSecureTextWarning', 'showSelectedOptionsFirst', 'showValueAsTags', 'silentInitialValueSet', 'textDirection', 'tooltipAlignment', 'tooltipFontSize', 'tooltipMaxWidth', 'updatePositionThrottle', 'useGroupValue', 'valueKey', 'zIndex'];

/** Class representing VirtualSelect */
class VirtualSelect {
Expand All @@ -683,6 +683,7 @@ class VirtualSelect {
this.setProps(options);
this.setDisabledOptions(options.disabledOptions);
this.setOptions(options.options);
this.warnIfSecureTextDisabled();
this.render();
} catch (e) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -1579,6 +1580,7 @@ class VirtualSelect {
this.showValueAsTags = convertToBoolean(options.showValueAsTags);
this.disableOptionGroupCheckbox = convertToBoolean(options.disableOptionGroupCheckbox);
this.enableSecureText = convertToBoolean(options.enableSecureText);
this.showSecureTextWarning = convertToBoolean(options.showSecureTextWarning, true);
this.setValueAsArray = convertToBoolean(options.setValueAsArray);
this.disableValidation = convertToBoolean(options.disableValidation);
this.initialDisabled = convertToBoolean(options.disabled);
Expand Down Expand Up @@ -1704,6 +1706,7 @@ class VirtualSelect {
additionalToggleButtonClasses: '',
maxValues: 0,
showDropboxAsPopup: true,
showSecureTextWarning: true,
popupDropboxBreakpoint: '576px',
popupPosition: 'center',
hideValueTooltipOnSelectAll: true,
Expand Down Expand Up @@ -3746,6 +3749,24 @@ class VirtualSelect {
this.$secureText.nodeValue = Utils.replaceDoubleQuotesWithHTML(text);
return this.$secureDiv.innerHTML;
}

/**
* Emit a single (per page) console warning when an instance is constructed while
* enableSecureText is disabled. enableSecureText is OFF by default to avoid the per-option
* escaping cost on large datasets (10k-100k+ records); this warning makes the XSS trade-off
* discoverable without forcing that cost on everyone. O(1): it never scans option content
* and fires on the configuration alone, so it is not missed when options are loaded later
* (e.g. via setOptions or server search).
*/
warnIfSecureTextDisabled() {
if (VirtualSelect.secureTextWarningShown || this.enableSecureText || !this.showSecureTextWarning) {
return;
}
VirtualSelect.secureTextWarningShown = true;

// eslint-disable-next-line no-console
console.warn('[virtual-select] Option text (label, value, description) and any `customData` used in ' + 'markup are rendered as HTML and are NOT escaped because `enableSecureText` is disabled ' + '(the default, kept off for performance on large datasets). If any option text can come ' + 'from untrusted input, set `enableSecureText: true` to prevent XSS. ' + 'Docs: https://sa-si-dev.github.io/virtual-select/#/properties');
}
toggleRequired(isRequired) {
this.required = Utils.convertToBoolean(isRequired);
this.$ele.required = this.required;
Expand Down Expand Up @@ -4002,6 +4023,9 @@ VirtualSelect.hasGlobalListeners = false;
// Static property for tracking the last interacted instance
VirtualSelect.lastInteractedInstance = null;

// Ensures the "enableSecureText disabled" warning is logged at most once per page
VirtualSelect.secureTextWarningShown = false;

/** polyfill to fix an issue in ie browser */
if (typeof NodeList !== 'undefined' && NodeList.prototype && !NodeList.prototype.forEach) {
NodeList.prototype.forEach = Array.prototype.forEach;
Expand Down
2 changes: 1 addition & 1 deletion dist/virtual-select.min.js

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion docs/assets/virtual-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ const keyDownMethodMapping = {
const valueLessProps = ['autofocus', 'disabled', 'multiple', 'required'];
const nativeProps = ['autofocus', 'class', 'disabled', 'id', 'multiple', 'name', 'placeholder', 'required'];
let attrPropsMapping;
const dataProps = ['additionalClasses', 'additionalDropboxClasses', 'additionalDropboxContainerClasses', 'additionalToggleButtonClasses', 'aliasKey', 'allOptionsSelectedText', 'allowNewOption', 'alwaysShowSelectedOptionsCount', 'alwaysShowSelectedOptionsLabel', 'ariaLabelledby', 'ariaLabelText', 'ariaLabelClearButtonText', 'ariaLabelTagClearButtonText', 'ariaLabelSearchClearButtonText', 'autoSelectFirstOption', 'clearButtonText', 'descriptionKey', 'disableAllOptionsSelectedText', 'disableOptionGroupCheckbox', 'disableSelectAll', 'disableValidation', 'dropboxWidth', 'dropboxWrapper', 'emptyValue', 'enableSecureText', 'focusSelectedOptionOnOpen', 'hasOptionDescription', 'hideClearButton', 'hideValueTooltipOnSelectAll', 'keepAlwaysOpen', 'labelKey', 'markSearchResults', 'maxValues', 'maxWidth', 'minValues', 'moreText', 'noOfDisplayValues', 'noOptionsText', 'noSearchResultsText', 'optionHeight', 'optionSelectedText', 'optionsCount', 'optionsSelectedText', 'popupDropboxBreakpoint', 'popupPosition', 'position', 'search', 'searchByStartsWith', 'searchDelay', 'searchFormLabel', 'searchGroup', 'searchNormalize', 'searchPlaceholderText', 'selectAllOnlyVisible', 'selectAllText', 'setValueAsArray', 'showDropboxAsPopup', 'showOptionsOnlyOnSearch', 'showSelectedOptionsFirst', 'showValueAsTags', 'silentInitialValueSet', 'textDirection', 'tooltipAlignment', 'tooltipFontSize', 'tooltipMaxWidth', 'updatePositionThrottle', 'useGroupValue', 'valueKey', 'zIndex'];
const dataProps = ['additionalClasses', 'additionalDropboxClasses', 'additionalDropboxContainerClasses', 'additionalToggleButtonClasses', 'aliasKey', 'allOptionsSelectedText', 'allowNewOption', 'alwaysShowSelectedOptionsCount', 'alwaysShowSelectedOptionsLabel', 'ariaLabelledby', 'ariaLabelText', 'ariaLabelClearButtonText', 'ariaLabelTagClearButtonText', 'ariaLabelSearchClearButtonText', 'autoSelectFirstOption', 'clearButtonText', 'descriptionKey', 'disableAllOptionsSelectedText', 'disableOptionGroupCheckbox', 'disableSelectAll', 'disableValidation', 'dropboxWidth', 'dropboxWrapper', 'emptyValue', 'enableSecureText', 'focusSelectedOptionOnOpen', 'hasOptionDescription', 'hideClearButton', 'hideValueTooltipOnSelectAll', 'keepAlwaysOpen', 'labelKey', 'markSearchResults', 'maxValues', 'maxWidth', 'minValues', 'moreText', 'noOfDisplayValues', 'noOptionsText', 'noSearchResultsText', 'optionHeight', 'optionSelectedText', 'optionsCount', 'optionsSelectedText', 'popupDropboxBreakpoint', 'popupPosition', 'position', 'search', 'searchByStartsWith', 'searchDelay', 'searchFormLabel', 'searchGroup', 'searchNormalize', 'searchPlaceholderText', 'selectAllOnlyVisible', 'selectAllText', 'setValueAsArray', 'showDropboxAsPopup', 'showOptionsOnlyOnSearch', 'showSecureTextWarning', 'showSelectedOptionsFirst', 'showValueAsTags', 'silentInitialValueSet', 'textDirection', 'tooltipAlignment', 'tooltipFontSize', 'tooltipMaxWidth', 'updatePositionThrottle', 'useGroupValue', 'valueKey', 'zIndex'];
Comment thread
gnbm marked this conversation as resolved.
Comment thread
gnbm marked this conversation as resolved.
Comment thread
gnbm marked this conversation as resolved.

/** Class representing VirtualSelect */
class VirtualSelect {
Expand All @@ -683,6 +683,7 @@ class VirtualSelect {
this.setProps(options);
this.setDisabledOptions(options.disabledOptions);
this.setOptions(options.options);
this.warnIfSecureTextDisabled();
this.render();
} catch (e) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -1579,6 +1580,7 @@ class VirtualSelect {
this.showValueAsTags = convertToBoolean(options.showValueAsTags);
this.disableOptionGroupCheckbox = convertToBoolean(options.disableOptionGroupCheckbox);
this.enableSecureText = convertToBoolean(options.enableSecureText);
this.showSecureTextWarning = convertToBoolean(options.showSecureTextWarning, true);
this.setValueAsArray = convertToBoolean(options.setValueAsArray);
this.disableValidation = convertToBoolean(options.disableValidation);
this.initialDisabled = convertToBoolean(options.disabled);
Expand Down Expand Up @@ -1704,6 +1706,7 @@ class VirtualSelect {
additionalToggleButtonClasses: '',
maxValues: 0,
showDropboxAsPopup: true,
showSecureTextWarning: true,
popupDropboxBreakpoint: '576px',
popupPosition: 'center',
hideValueTooltipOnSelectAll: true,
Expand Down Expand Up @@ -3746,6 +3749,24 @@ class VirtualSelect {
this.$secureText.nodeValue = Utils.replaceDoubleQuotesWithHTML(text);
return this.$secureDiv.innerHTML;
}

/**
* Emit a single (per page) console warning when an instance is constructed while
* enableSecureText is disabled. enableSecureText is OFF by default to avoid the per-option
* escaping cost on large datasets (10k-100k+ records); this warning makes the XSS trade-off
* discoverable without forcing that cost on everyone. O(1): it never scans option content
* and fires on the configuration alone, so it is not missed when options are loaded later
* (e.g. via setOptions or server search).
*/
warnIfSecureTextDisabled() {
if (VirtualSelect.secureTextWarningShown || this.enableSecureText || !this.showSecureTextWarning) {
return;
}
VirtualSelect.secureTextWarningShown = true;

// eslint-disable-next-line no-console
console.warn('[virtual-select] Option text (label, value, description) and any `customData` used in ' + 'markup are rendered as HTML and are NOT escaped because `enableSecureText` is disabled ' + '(the default, kept off for performance on large datasets). If any option text can come ' + 'from untrusted input, set `enableSecureText: true` to prevent XSS. ' + 'Docs: https://sa-si-dev.github.io/virtual-select/#/properties');
}
toggleRequired(isRequired) {
this.required = Utils.convertToBoolean(isRequired);
this.$ele.required = this.required;
Expand Down Expand Up @@ -4002,6 +4023,9 @@ VirtualSelect.hasGlobalListeners = false;
// Static property for tracking the last interacted instance
VirtualSelect.lastInteractedInstance = null;

// Ensures the "enableSecureText disabled" warning is logged at most once per page
VirtualSelect.secureTextWarningShown = false;

/** polyfill to fix an issue in ie browser */
if (typeof NodeList !== 'undefined' && NodeList.prototype && !NodeList.prototype.forEach) {
NodeList.prototype.forEach = Array.prototype.forEach;
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/virtual-select.min.js

Large diffs are not rendered by default.

Loading