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
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ jobs:
- name: Build Library
run: pnpm build:lib

- name: Run Prototype Pollution Security Tests
run: pnpm test:security

- name: Build Website (GitHub demo site)
run: pnpm build:demo

Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"new-publish": "lerna publish from-package",
"roll-new-release": "pnpm build && pnpm new-version && pnpm new-publish",
"serve:demo": "pnpm -r --stream --filter=\"{packages/demo/**}\" dev",
"test:security": "pnpm -r --stream --filter=\"{packages/multiple-select-vanilla/**}\" test:security",
"test:e2e": "remove playwright-report && playwright test --config playwright/playwright.config.ts",
"test:e2e:debug": "playwright test --config playwright/playwright.config.ts --ui --debug",
"test:e2e:ui": "playwright test --config playwright/playwright.config.ts --ui",
Expand All @@ -56,13 +57,13 @@
"pnpm": "11.x"
},
"devDependencies": {
"@biomejs/biome": "^2.5.7",
"@lerna-lite/cli": "^5.4.2",
"@lerna-lite/publish": "^5.4.2",
"@lerna-lite/watch": "^5.4.2",
"@biomejs/biome": "^2.5.9",
"@lerna-lite/cli": "^5.6.1",
"@lerna-lite/publish": "^5.6.1",
"@lerna-lite/watch": "^5.6.1",
"@playwright/test": "^1.62.1",
"@types/node": "^26.1.2",
"conventional-changelog-conventionalcommits": "^10.2.1",
"@types/node": "^26.2.0",
"conventional-changelog-conventionalcommits": "^10.4.0",
"cross-env": "catalog:",
"npm-run-all2": "^9.0.3",
"remove-glob": "catalog:",
Expand Down
1 change: 0 additions & 1 deletion packages/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'font-awesome/css/font-awesome.css';
import { createDomElement, emptyElement } from 'multiple-select-vanilla';

import { exampleRouting, navbarRouting } from './app-routing.js';
// biome-ignore lint/correctness/useImportExtensions: false positive
import mainHtml from './main.html?raw';
import './style.scss';

Expand Down
3 changes: 2 additions & 1 deletion packages/multiple-select-vanilla/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ tsconfig.tsbuildinfo
node_modules
build-prod.mjs
build-watch.mjs
CHANGELOG.md
CHANGELOG.md
test
3 changes: 2 additions & 1 deletion packages/multiple-select-vanilla/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"sass:build": "sass src/styles:dist/styles/css --style=compressed --quiet-deps --no-source-map && pnpm sass:build:closing",
"sass:build:closing": "postcss dist/styles/css/**/* --dir dist/styles/css --base dist/styles/css --no-map --use cssnano --use autoprefixer --style=compressed",
"sass:watch": "sass src/styles:dist/styles/css --watch --style=compressed --quiet-deps --no-source-map",
"sass:copy": "copyfiles \"./src/styles/**/*.scss\" dist/styles/sass --up 2 --stat"
"sass:copy": "copyfiles \"./src/styles/**/*.scss\" dist/styles/sass --up 2 --stat",
"test:security": "pnpm build:all && node --test test/*.test.mjs"
},
"dependencies": {
"@types/trusted-types": "^2.0.7"
Expand Down
23 changes: 10 additions & 13 deletions packages/multiple-select-vanilla/src/MultipleSelectInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class MultipleSelectInstance {
protected elm: HTMLInputElement | HTMLSelectElement | HTMLSpanElement,
options?: Partial<Omit<MultipleSelectOption, 'onHardDestroy' | 'onAfterHardDestroy'>>,
) {
this.options = Object.assign({}, Constants.DEFAULTS, this.elm.dataset, options) as MultipleSelectOption;
this.options = { ...Constants.DEFAULTS, ...this.elm.dataset, ...options } as MultipleSelectOption;
this._bindEventService = new BindingEventService({ distinctEvent: true });
}

Expand Down Expand Up @@ -132,28 +132,25 @@ export class MultipleSelectInstance {
protected initLocale() {
if (this.options.locale) {
if (typeof this.options.locale === 'object') {
Object.assign(this.options, this.options.locale);
this.options = { ...this.options, ...this.options.locale };
return;
}

// Use locales from options, fallback to instance property
const locales = (this.options.locales || this.locales || {}) as Record<string, MultipleSelectLocale>;
// Use locales from own options only, fallback to instance property
const optionLocales = Object.prototype.hasOwnProperty.call(this.options, 'locales') ? this.options.locales : undefined;
const locales = (optionLocales || this.locales || {}) as Record<string, MultipleSelectLocale>;
const parts = this.options.locale.split(/-|_/);

parts[0] = parts[0].toLowerCase();
if (parts[1]) {
parts[1] = parts[1].toUpperCase();
}

if (locales[this.options.locale]) {
Object.assign(this.options, locales[this.options.locale]);
} else if (locales[parts.join('-')]) {
Object.assign(this.options, locales[parts.join('-')]);
} else if (locales[parts[0]]) {
Object.assign(this.options, locales[parts[0]]);
} else {
const localeKey = [this.options.locale, parts.join('-'), parts[0]].find(key => Object.prototype.hasOwnProperty.call(locales, key));
if (!localeKey) {
throw new Error(`[multiple-select-vanilla] invalid locales "${this.options.locale}", make sure to import it before using it`);
}
this.options = { ...this.options, ...locales[localeKey] };
}
}

Expand Down Expand Up @@ -1664,7 +1661,7 @@ export class MultipleSelectInstance {
*/
getOptions(returnDeepCopy = true) {
// deep copy and remove data
const options = Object.assign({}, this.options);
const options = { ...this.options };
delete options.data;

return returnDeepCopy ? deepCopy<MultipleSelectOption>(options) : this.options;
Expand All @@ -1675,7 +1672,7 @@ export class MultipleSelectInstance {
if (compareObjects(this.options, options, true)) {
return;
}
this.options = Object.assign(this.options, options);
this.options = { ...this.options, ...options };
this.destroy(false);
this.init();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class VirtualScroll {
this.parentEl = options.contentEl?.parentElement;
this.callback = options.callback;

this.cache = {} as VirtualCache;
this.cache = Object.create(null) as VirtualCache;
this.scrollTop = this.scrollEl.scrollTop;

this.initDOM(this.rows);
Expand All @@ -51,7 +51,7 @@ export class VirtualScroll {

reset(rows: HtmlStruct[]) {
this.lastCluster = 0;
this.cache = {} as any;
this.cache = Object.create(null) as VirtualCache;
emptyElement(this.contentEl);
this.initDOM(rows);
}
Expand Down
19 changes: 16 additions & 3 deletions packages/multiple-select-vanilla/src/utils/domUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import type { HtmlStruct, InferDOMType } from '../models/interfaces.js';
import { isDefined, objectRemoveEmptyProps } from './utils.js';

const UNSAFE_DOM_PROPERTY_NAMES = new Set(['__proto__', 'prototype', 'constructor', 'toString', 'valueOf', 'hasOwnProperty']);

function assertSafeDomProperties(properties: object) {
for (const propertyName of Object.keys(properties)) {
if (UNSAFE_DOM_PROPERTY_NAMES.has(propertyName)) {
throw new TypeError(`[multiple-select-vanilla] unsafe DOM property name "${propertyName}"`);
}
}
}

export interface HtmlElementPosition {
top: number;
bottom: number;
Expand Down Expand Up @@ -51,10 +61,13 @@ export function createDomElement<T extends keyof HTMLElementTagNameMap, K extend
const elm = document.createElement<T>(tagName);

if (elementOptions) {
assertSafeDomProperties(elementOptions);
Object.keys(elementOptions).forEach(elmOptionKey => {
const elmValue = elementOptions[elmOptionKey as keyof typeof elementOptions];
if (typeof elmValue === 'object') {
Object.assign(elm[elmOptionKey as K] as object, elmValue);
const elmTarget = elm[elmOptionKey as K];
if (typeof elmValue === 'object' && elmValue !== null && typeof elmTarget === 'object' && elmTarget !== null) {
assertSafeDomProperties(elmValue);
Object.assign(elmTarget, elmValue);
} else {
elm[elmOptionKey as K] = (elementOptions as any)[elmOptionKey as keyof typeof elementOptions];
}
Expand Down Expand Up @@ -104,7 +117,7 @@ export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement,

/** takes an html block object and converts to a real HTMLElement */
export function convertItemRowToHtml(item: HtmlStruct): HTMLElement {
if (item.hasOwnProperty('tagName')) {
if (Object.prototype.hasOwnProperty.call(item, 'tagName')) {
return createDomStructure(item);
}
return document.createElement('li');
Expand Down
155 changes: 155 additions & 0 deletions packages/multiple-select-vanilla/test/prototype-pollution.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import { after, test } from 'node:test';

import { MultipleSelectInstance, VirtualScroll, convertItemRowToHtml, createDomElement } from '../dist/index.js';

const originalDocument = globalThis.document;

after(() => {
globalThis.document = originalDocument;
});

test('createDomElement rejects prototype and inherited built-in property names', () => {
const elementPrototype = {};
globalThis.document = {
createElement: () => Object.create(elementPrototype),
};

for (const propertyName of ['__proto__', 'prototype', 'constructor', 'toString', 'valueOf', 'hasOwnProperty']) {
const properties = { [propertyName]: { polluted: propertyName } };
assert.throws(() => createDomElement('div', properties), /unsafe DOM property name/);
}

assert.equal(elementPrototype.polluted, undefined);
assert.equal(Object.polluted, undefined);
assert.equal(Object.prototype.toString.polluted, undefined);
assert.equal(Object.prototype.valueOf.polluted, undefined);
assert.equal(Object.prototype.hasOwnProperty.polluted, undefined);
});

test('createDomElement still assigns ordinary and nested DOM properties', () => {
const style = {};
const dataset = {};
globalThis.document = {
createElement: () => ({ style, dataset }),
};

const element = createDomElement('div', {
className: 'safe-class',
dataset: { key: 'safe-key' },
style: { display: 'none' },
});

assert.equal(element.className, 'safe-class');
assert.deepEqual(element.dataset, { key: 'safe-key' });
assert.deepEqual(element.style, { display: 'none' });
});

test('createDomElement does not merge objects into inherited functions', () => {
const addEventListener = () => {};
const elementPrototype = { addEventListener };
globalThis.document = {
createElement: () => Object.create(elementPrototype),
};

const payload = { polluted: true };
const element = createDomElement('div', { addEventListener: payload });

assert.equal(addEventListener.polluted, undefined);
assert.equal(Object.hasOwn(element, 'addEventListener'), true);
assert.equal(element.addEventListener, payload);
});

test('convertItemRowToHtml supports prototype-free and overridden input objects', () => {
globalThis.document = {
createElement: tagName => ({ tagName, appendChild: () => {}, setAttribute: () => {} }),
};

const prototypeFreeItem = Object.assign(Object.create(null), { tagName: 'div', props: {} });
const overriddenItem = { tagName: 'span', props: {}, hasOwnProperty: null };

assert.equal(convertItemRowToHtml(prototypeFreeItem).tagName, 'div');
assert.equal(convertItemRowToHtml(overriddenItem).tagName, 'span');
});

test('VirtualScroll uses a prototype-free cache across resets', () => {
const createElement = tagName => ({ tagName, appendChild: () => {}, setAttribute: () => {}, offsetHeight: 10 });
globalThis.document = { createElement };

const children = [];
const contentElement = {
children,
parentElement: null,
appendChild: child => children.push(child),
removeChild: child => children.splice(children.indexOf(child), 1),
get firstChild() {
return children[0];
},
get lastChild() {
return children.at(-1);
},
};
const scrollElement = {
scrollTop: 0,
addEventListener: () => {},
removeEventListener: () => {},
};
const rows = [{ tagName: 'li', props: { className: 'row' } }];

const virtualScroll = new VirtualScroll({ rows, scrollEl: scrollElement, contentEl: contentElement, callback: () => {} });
assert.equal(Object.getPrototypeOf(virtualScroll.cache), null);

virtualScroll.reset(rows);
assert.equal(Object.getPrototypeOf(virtualScroll.cache), null);
});

test('options treat special names as own data without changing their prototype', () => {
const maliciousOptions = JSON.parse('{"__proto__":{"polluted":true},"constructor":{"polluted":true},"toString":{"polluted":true}}');
const instance = new MultipleSelectInstance({ dataset: {} }, maliciousOptions);
const options = instance.getOptions(false);

assert.equal(Object.getPrototypeOf(options), Object.prototype);
assert.equal(
Object.getOwnPropertyDescriptor(options, '__proto__')?.value,
Object.getOwnPropertyDescriptor(maliciousOptions, '__proto__')?.value,
);
assert.equal(Object.prototype.polluted, undefined);
assert.equal(Object.polluted, undefined);
assert.equal(Object.prototype.toString.polluted, undefined);
});

test('refreshOptions cannot replace the options prototype', () => {
const instance = new MultipleSelectInstance({ dataset: {} });
instance.destroy = () => {};
instance.init = () => {};

const maliciousOptions = JSON.parse('{"__proto__":{"polluted":true}}');
instance.refreshOptions(maliciousOptions);
const options = instance.getOptions(false);

assert.equal(Object.getPrototypeOf(options), Object.prototype);
assert.equal(
Object.getOwnPropertyDescriptor(options, '__proto__')?.value,
Object.getOwnPropertyDescriptor(maliciousOptions, '__proto__')?.value,
);
assert.equal(Object.prototype.polluted, undefined);
});

test('locale lookup ignores inherited option and registry properties', () => {
const localeName = 'polluted-locale';
const pollutedLocale = {
formatSelectAll: () => 'polluted',
};
Object.prototype.locales = { [localeName]: pollutedLocale };

try {
const instance = new MultipleSelectInstance({ dataset: {} }, { locale: localeName });
assert.throws(() => instance.initLocale(), /invalid locales/);
} finally {
delete Object.prototype.locales;
}

const instance = new MultipleSelectInstance({ dataset: {} }, { locale: localeName });
instance.locales = Object.create({ [localeName]: pollutedLocale });
assert.throws(() => instance.initLocale(), /invalid locales/);
});
Loading
Loading