Skip to content
Closed
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
71 changes: 71 additions & 0 deletions cypress/e2e/perf-resize-throttle.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** cSpell:ignore vscomp */

// Tests for P1: the global window 'resize' handler used to run per-instance height
// recomputation on every resize tick, unthrottled, and threw if a wrapper had no instance.

describe('Performance: window resize handler (P1)', () => {
const mountId = 'vs-p1-resize';

const mount = (win: Window) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}
const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);
// @ts-expect-error - VirtualSelect attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] });
return $ele;
};

it('throttles resize so onResize runs far fewer times than resize events fire', () => {
cy.visit('get-started');
cy.window().then((win) => {
mount(win);
// @ts-expect-error - instance back-reference
const instance = win.document.getElementById(mountId).virtualSelect;
cy.spy(instance, 'onResize').as('onResize');

for (let i = 0; i < 10; i += 1) {
win.dispatchEvent(new win.Event('resize'));
}
});

// allow the trailing-edge call to fire
cy.wait(250);
cy.get('@onResize').its('callCount').should('be.greaterThan', 0).and('be.lessThan', 10);
});

it('still updates the dropdown on resize (no regression)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).find('.vscomp-option').should('have.length.greaterThan', 0);

cy.window().then((win) => win.dispatchEvent(new win.Event('resize')));
cy.wait(150);

// dropdown remains open and functional after a resize
cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('not.have.class', 'closed');
cy.get(`#${mountId}`).find('.vscomp-options-container').should('exist');
});

it('does not throw when a wrapper has no associated instance (guard)', () => {
cy.visit('get-started');
cy.window().then((win) => {
const doc = win.document;
const orphan = doc.createElement('div');
orphan.innerHTML = '<div class="vscomp-ele-wrapper"></div>'; // parent has no .virtualSelect
doc.body.appendChild(orphan);

// would throw "Cannot read properties of undefined (reading 'onResize')" without the guard
win.dispatchEvent(new win.Event('resize'));
});

// if the handler threw, Cypress would have failed the test on the uncaught exception
cy.get('body').should('exist');
});
});
58 changes: 58 additions & 0 deletions cypress/e2e/security-classnames-xss.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** cSpell:ignore vscomp classnames */

// Regression test for S3: option `classNames` was concatenated into the class attribute
// without sanitization, allowing attribute-breakout injection. Sanitization must strip the
// breakout characters (" < >) while leaving legitimate class names untouched.

describe('Security: classNames attribute-injection (S3)', () => {
const mountId = 'vs-s3-classnames';

const mount = (win: Window, options: unknown[]) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}

const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);

// @ts-expect-error - VirtualSelect is attached to window by the bundle
win.VirtualSelect.init({ ele: $ele, options });
return $ele;
};

it('does not inject markup from a malicious classNames value', () => {
cy.visit('get-started');
cy.window().then((win) => {
// @ts-expect-error - test marker
win.__vsXssS3 = undefined;
mount(win, [{ label: 'A', value: 'a', classNames: 'foo"><img src=x onerror="window.__vsXssS3=true' }]);
});

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();

// the option still renders as a normal vscomp-option (no broken markup)...
cy.get(`#${mountId}`).find('.vscomp-option[data-value="a"]').should('have.class', 'vscomp-option');
// ...but no element was injected and the handler never ran.
cy.get('img[src="x"]').should('not.exist');
cy.window().then((win) => {
// @ts-expect-error - test marker
expect(win.__vsXssS3, 'XSS payload should not execute').to.not.eq(true);
});
});

it('preserves normal class names (no regression for non-malicious input)', () => {
cy.visit('get-started');
cy.window().then((win) => {
mount(win, [{ label: 'B', value: 'b', classNames: 'my-custom-class another-class' }]);
});

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`)
.find('.vscomp-option[data-value="b"]')
.should('have.class', 'my-custom-class')
.and('have.class', 'another-class');
});
});
71 changes: 71 additions & 0 deletions cypress/e2e/security-customdata-xss.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** cSpell:ignore vscomp vsele */

// Regression test for S2: customData fields (group_name / description) were interpolated
// into the option aria-label attribute without escaping, allowing an attribute-breakout XSS
// even when enableSecureText was enabled.

describe('Security: customData attribute-injection (S2)', () => {
const mountId = 'vs-s2-xss';
const payload = '"><img src=x onerror="window.__vsXssS2=true">';

const mount = (win: Window, extra: Record<string, unknown> = {}) => {
const doc = win.document;
const existing = doc.getElementById(mountId);
if (existing) {
existing.remove();
}

const $ele = doc.createElement('div');
$ele.id = mountId;
doc.body.appendChild($ele);

// @ts-expect-error - VirtualSelect is attached to window by the bundle
win.VirtualSelect.init({
ele: $ele,
enableSecureText: true,
options: [
{
label: 'Group',
options: [{ label: 'Child', value: 'c1', customData: { group_name: payload, description: payload } }],
},
],
...extra,
});

return $ele;
};

it('does not inject markup from customData and does not execute the payload', () => {
cy.visit('get-started');

cy.window().then((win) => {
// @ts-expect-error - test marker
win.__vsXssS2 = undefined;
mount(win);
});

// open the dropdown so the options (and their aria-label) are rendered
cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();

// the option must render...
cy.get(`#${mountId}`).find('.vscomp-option[data-value="c1"]').should('exist');
// ...but the breakout payload must NOT have created a real <img> element...
cy.get('img[src="x"]').should('not.exist');
// ...and the onerror handler must never have run.
cy.window().then((win) => {
// @ts-expect-error - test marker
expect(win.__vsXssS2, 'XSS payload should not execute').to.not.eq(true);
});
});

it('keeps the aria-label intact (escaped, not broken out of the attribute)', () => {
cy.visit('get-started');
cy.window().then((win) => mount(win));

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`)
.find('.vscomp-option[data-value="c1"]')
.should('have.attr', 'aria-label')
.and('contain', 'Child');
});
});
2 changes: 1 addition & 1 deletion dist-archive/virtual-select-1.2.0.min.js

Large diffs are not rendered by default.

Loading