|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { after, test } from 'node:test'; |
| 3 | + |
| 4 | +import { MultipleSelectInstance, VirtualScroll, convertItemRowToHtml, createDomElement } from '../dist/index.js'; |
| 5 | + |
| 6 | +const originalDocument = globalThis.document; |
| 7 | + |
| 8 | +after(() => { |
| 9 | + globalThis.document = originalDocument; |
| 10 | +}); |
| 11 | + |
| 12 | +test('createDomElement rejects prototype and inherited built-in property names', () => { |
| 13 | + const elementPrototype = {}; |
| 14 | + globalThis.document = { |
| 15 | + createElement: () => Object.create(elementPrototype), |
| 16 | + }; |
| 17 | + |
| 18 | + for (const propertyName of ['__proto__', 'prototype', 'constructor', 'toString', 'valueOf', 'hasOwnProperty']) { |
| 19 | + const properties = { [propertyName]: { polluted: propertyName } }; |
| 20 | + assert.throws(() => createDomElement('div', properties), /unsafe DOM property name/); |
| 21 | + } |
| 22 | + |
| 23 | + assert.equal(elementPrototype.polluted, undefined); |
| 24 | + assert.equal(Object.polluted, undefined); |
| 25 | + assert.equal(Object.prototype.toString.polluted, undefined); |
| 26 | + assert.equal(Object.prototype.valueOf.polluted, undefined); |
| 27 | + assert.equal(Object.prototype.hasOwnProperty.polluted, undefined); |
| 28 | +}); |
| 29 | + |
| 30 | +test('createDomElement still assigns ordinary and nested DOM properties', () => { |
| 31 | + const style = {}; |
| 32 | + const dataset = {}; |
| 33 | + globalThis.document = { |
| 34 | + createElement: () => ({ style, dataset }), |
| 35 | + }; |
| 36 | + |
| 37 | + const element = createDomElement('div', { |
| 38 | + className: 'safe-class', |
| 39 | + dataset: { key: 'safe-key' }, |
| 40 | + style: { display: 'none' }, |
| 41 | + }); |
| 42 | + |
| 43 | + assert.equal(element.className, 'safe-class'); |
| 44 | + assert.deepEqual(element.dataset, { key: 'safe-key' }); |
| 45 | + assert.deepEqual(element.style, { display: 'none' }); |
| 46 | +}); |
| 47 | + |
| 48 | +test('createDomElement does not merge objects into inherited functions', () => { |
| 49 | + const addEventListener = () => {}; |
| 50 | + const elementPrototype = { addEventListener }; |
| 51 | + globalThis.document = { |
| 52 | + createElement: () => Object.create(elementPrototype), |
| 53 | + }; |
| 54 | + |
| 55 | + const payload = { polluted: true }; |
| 56 | + const element = createDomElement('div', { addEventListener: payload }); |
| 57 | + |
| 58 | + assert.equal(addEventListener.polluted, undefined); |
| 59 | + assert.equal(Object.hasOwn(element, 'addEventListener'), true); |
| 60 | + assert.equal(element.addEventListener, payload); |
| 61 | +}); |
| 62 | + |
| 63 | +test('convertItemRowToHtml supports prototype-free and overridden input objects', () => { |
| 64 | + globalThis.document = { |
| 65 | + createElement: tagName => ({ tagName, appendChild: () => {}, setAttribute: () => {} }), |
| 66 | + }; |
| 67 | + |
| 68 | + const prototypeFreeItem = Object.assign(Object.create(null), { tagName: 'div', props: {} }); |
| 69 | + const overriddenItem = { tagName: 'span', props: {}, hasOwnProperty: null }; |
| 70 | + |
| 71 | + assert.equal(convertItemRowToHtml(prototypeFreeItem).tagName, 'div'); |
| 72 | + assert.equal(convertItemRowToHtml(overriddenItem).tagName, 'span'); |
| 73 | +}); |
| 74 | + |
| 75 | +test('VirtualScroll uses a prototype-free cache across resets', () => { |
| 76 | + const createElement = tagName => ({ tagName, appendChild: () => {}, setAttribute: () => {}, offsetHeight: 10 }); |
| 77 | + globalThis.document = { createElement }; |
| 78 | + |
| 79 | + const children = []; |
| 80 | + const contentElement = { |
| 81 | + children, |
| 82 | + parentElement: null, |
| 83 | + appendChild: child => children.push(child), |
| 84 | + removeChild: child => children.splice(children.indexOf(child), 1), |
| 85 | + get firstChild() { |
| 86 | + return children[0]; |
| 87 | + }, |
| 88 | + get lastChild() { |
| 89 | + return children.at(-1); |
| 90 | + }, |
| 91 | + }; |
| 92 | + const scrollElement = { |
| 93 | + scrollTop: 0, |
| 94 | + addEventListener: () => {}, |
| 95 | + removeEventListener: () => {}, |
| 96 | + }; |
| 97 | + const rows = [{ tagName: 'li', props: { className: 'row' } }]; |
| 98 | + |
| 99 | + const virtualScroll = new VirtualScroll({ rows, scrollEl: scrollElement, contentEl: contentElement, callback: () => {} }); |
| 100 | + assert.equal(Object.getPrototypeOf(virtualScroll.cache), null); |
| 101 | + |
| 102 | + virtualScroll.reset(rows); |
| 103 | + assert.equal(Object.getPrototypeOf(virtualScroll.cache), null); |
| 104 | +}); |
| 105 | + |
| 106 | +test('options treat special names as own data without changing their prototype', () => { |
| 107 | + const maliciousOptions = JSON.parse('{"__proto__":{"polluted":true},"constructor":{"polluted":true},"toString":{"polluted":true}}'); |
| 108 | + const instance = new MultipleSelectInstance({ dataset: {} }, maliciousOptions); |
| 109 | + const options = instance.getOptions(false); |
| 110 | + |
| 111 | + assert.equal(Object.getPrototypeOf(options), Object.prototype); |
| 112 | + assert.equal( |
| 113 | + Object.getOwnPropertyDescriptor(options, '__proto__')?.value, |
| 114 | + Object.getOwnPropertyDescriptor(maliciousOptions, '__proto__')?.value, |
| 115 | + ); |
| 116 | + assert.equal(Object.prototype.polluted, undefined); |
| 117 | + assert.equal(Object.polluted, undefined); |
| 118 | + assert.equal(Object.prototype.toString.polluted, undefined); |
| 119 | +}); |
| 120 | + |
| 121 | +test('refreshOptions cannot replace the options prototype', () => { |
| 122 | + const instance = new MultipleSelectInstance({ dataset: {} }); |
| 123 | + instance.destroy = () => {}; |
| 124 | + instance.init = () => {}; |
| 125 | + |
| 126 | + const maliciousOptions = JSON.parse('{"__proto__":{"polluted":true}}'); |
| 127 | + instance.refreshOptions(maliciousOptions); |
| 128 | + const options = instance.getOptions(false); |
| 129 | + |
| 130 | + assert.equal(Object.getPrototypeOf(options), Object.prototype); |
| 131 | + assert.equal( |
| 132 | + Object.getOwnPropertyDescriptor(options, '__proto__')?.value, |
| 133 | + Object.getOwnPropertyDescriptor(maliciousOptions, '__proto__')?.value, |
| 134 | + ); |
| 135 | + assert.equal(Object.prototype.polluted, undefined); |
| 136 | +}); |
| 137 | + |
| 138 | +test('locale lookup ignores inherited option and registry properties', () => { |
| 139 | + const localeName = 'polluted-locale'; |
| 140 | + const pollutedLocale = { |
| 141 | + formatSelectAll: () => 'polluted', |
| 142 | + }; |
| 143 | + Object.prototype.locales = { [localeName]: pollutedLocale }; |
| 144 | + |
| 145 | + try { |
| 146 | + const instance = new MultipleSelectInstance({ dataset: {} }, { locale: localeName }); |
| 147 | + assert.throws(() => instance.initLocale(), /invalid locales/); |
| 148 | + } finally { |
| 149 | + delete Object.prototype.locales; |
| 150 | + } |
| 151 | + |
| 152 | + const instance = new MultipleSelectInstance({ dataset: {} }, { locale: localeName }); |
| 153 | + instance.locales = Object.create({ [localeName]: pollutedLocale }); |
| 154 | + assert.throws(() => instance.initLocale(), /invalid locales/); |
| 155 | +}); |
0 commit comments