Skip to content

ComboBox - New methods + bullet proofing #479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
109 changes: 77 additions & 32 deletions src/plugins/combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
IComboBox,
IComboBoxOptions,
IComboBoxItemAttr,
QueryTransformer,
} from '../combobox/interfaces';

import HSBasePlugin from '../base-plugin';
Expand All @@ -34,6 +35,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
apiDataPart: string | null;
apiQuery: string | null;
apiSearchQuery: string | null;
apiSearchQueryTransformer: ((query: string) => string) | null;
apiHeaders: {};
apiGroupField: string | null;
outputItemTemplate: string | null;
Expand All @@ -50,6 +52,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
private readonly output: HTMLElement | null;
private readonly itemsWrapper: HTMLElement | null;
private items: HTMLElement[] | [];
private selectedItemElement: HTMLElement | null;
private tabs: HTMLElement[] | [];
private readonly toggle: HTMLElement | null;
private readonly toggleClose: HTMLElement | null;
Expand Down Expand Up @@ -87,6 +90,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
this.apiDataPart = concatOptions?.apiDataPart ?? null;
this.apiQuery = concatOptions?.apiQuery ?? null;
this.apiSearchQuery = concatOptions?.apiSearchQuery ?? null;
this.apiSearchQueryTransformer = this.parseApiQueryTransformer(concatOptions?.apiSearchQueryTransformer);
this.apiHeaders = concatOptions?.apiHeaders ?? {};
this.apiGroupField = concatOptions?.apiGroupField ?? null;
this.outputItemTemplate =
Expand Down Expand Up @@ -150,7 +154,20 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
this.init();
}

private parseApiQueryTransformer(query: QueryTransformer | string | null): QueryTransformer | null {

if (!query) return null;

if (typeof query === 'string') {
return eval(query) as QueryTransformer;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mohsinhijazee , thanks for corrections.

I noticed that you've added eval in the parseApiQueryTransformer method. eval is known to be a potential security risk, especially when handling dynamic inputs, as it can allow arbitrary code execution.

This raises some concerns on our side, as we strive to keep the codebase secure and avoid introducing vulnerabilities. Could you please explain why eval was necessary here and if there might be a safer alternative?

We need to ensure that all code changes are secure and well-justified, so your clarification would be appreciated.

Thanks.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this eval is used so that the users of the component can utilise data-* attributes to setup the search function (transforming the query before it is sent to the server) without going to write further Javascript (and instantiating a control in pure JS) therefore this does not come from the user input per se, rather from the developer/user of the library and I believe this is a safe use case and can't be exploited because it is not linked to an input control that can be manipulated by the end users to change things around.

Even data attribute though dynamically can be changed, but that change won't trigger the eval because combobox would be initialised already.

Other alternative is to drop the ability of being able to supply a search function via data-* attributes which would result in a component that is less capable when used in typical HTML only scenarios.

}

return query;
}

private init() {
// So that not to dpeendon preloading whole library.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small typo in the comment.

window.$hsComboBoxCollection = window.$hsComboBoxCollection ?? [];
this.createCollection(window.$hsComboBoxCollection, this);

this.build();
Expand Down Expand Up @@ -239,10 +256,10 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
const equality =
params?.group?.name && group
? group === params.group.name &&
elI.getAttribute('data-hs-combo-box-search-text') ===
obj[elI.getAttribute('data-hs-combo-box-output-item-field')]
elI.getAttribute('data-hs-combo-box-search-text') ===
obj[elI.getAttribute('data-hs-combo-box-output-item-field')]
: elI.getAttribute('data-hs-combo-box-search-text') ===
obj[elI.getAttribute('data-hs-combo-box-output-item-field')];
obj[elI.getAttribute('data-hs-combo-box-output-item-field')];

return equality;
});
Expand Down Expand Up @@ -319,7 +336,9 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {

try {
const query = `${this.apiQuery}`;
const searchQuery = `${this.apiSearchQuery}=${this.value.toLowerCase()}`;
const initialSearchQuery = `${this.apiSearchQuery}=${this.value.toLowerCase()}`;
const searchQuery = this.apiSearchQueryTransformer ? this.apiSearchQueryTransformer(this.value) : initialSearchQuery;

let url = this.apiUrl;
if (this.apiQuery && this.apiSearchQuery) {
url += `?${searchQuery}&${query}`;
Expand Down Expand Up @@ -376,6 +395,12 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
}

private jsonItemsRender(items: any) {

// Bullet proofing.
if (Array.isArray(items)) {
return
}

items.forEach((item: never, index: number) => {
// TODO:: test without checking below
// if (this.isItemExists(item)) return false;
Expand All @@ -401,6 +426,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
item[el.getAttribute('data-hs-combo-box-output-item-field')] ?? '',
);
});
// FIXME: Move to combobox options
newItem
.querySelectorAll('[data-hs-combo-box-output-item-attr]')
.forEach((el) => {
Expand All @@ -416,8 +442,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
if (this.groupingType === 'tabs' || this.groupingType === 'default') {
newItem.setAttribute(
'data-hs-combo-box-output-item',
`{"group": {"name": "${item[this.apiGroupField]}", "title": "${
item[this.apiGroupField]
`{"group": {"name": "${item[this.apiGroupField]}", "title": "${item[this.apiGroupField]
}"}}`,
);
}
Expand All @@ -426,13 +451,13 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {

if (!this.preventSelection) {
(newItem as HTMLElement).addEventListener('click', () => {
this.selectedItemElement = newItem;
this.setSelectedByValue(this.valuesBySelector(newItem));
this.close(
(newItem as HTMLElement)
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text'),
);

this.setSelectedByValue(this.valuesBySelector(newItem));
});
}

Expand Down Expand Up @@ -645,6 +670,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
});
}

// FIXME: Does not go through the setSelectedByValue method.
private setValue(val: string) {
this.selected = val;
this.value = val;
Expand All @@ -666,12 +692,12 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
? this.selectedGroup === 'all'
? this.items
: this.items.filter((f: HTMLElement) => {
const { group } = JSON.parse(
f.getAttribute('data-hs-combo-box-output-item'),
);
const { group } = JSON.parse(
f.getAttribute('data-hs-combo-box-output-item'),
);

return group.name === this.selectedGroup;
})
return group.name === this.selectedGroup;
})
: this.items;

if (this.groupingType === 'tabs' && this.selectedGroup !== 'all') {
Expand Down Expand Up @@ -739,7 +765,6 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
this.setSelectedByValue([this.selected]);
}

// Public methods
private setValueAndOpen(val: string) {
this.value = val;

Expand All @@ -748,6 +773,15 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
}
}

private setValueAndClear(val: string | null) {
if (val) this.setValue(val);
else this.setValue(this.selected);

if (this.outputPlaceholder) this.destroyOutputPlaceholder();
}


// Public methods
public open(val?: string) {
if (this.animationInProcess) return false;

Expand All @@ -771,13 +805,6 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
this.isOpened = true;
}

private setValueAndClear(val: string | null) {
if (val) this.setValue(val);
else this.setValue(this.selected);

if (this.outputPlaceholder) this.destroyOutputPlaceholder();
}

public close(val?: string | null) {
if (this.animationInProcess) return false;

Expand All @@ -803,18 +830,36 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {

afterTransition(this.output, () => {
this.output.style.display = 'none';

this.setValueAndClear(val);

this.animationInProcess = false;
});

if (this.input.value !== '') this.el.classList.add('has-value');
else this.el.classList.remove('has-value');


this.isOpened = false;
}

setSearchQueryTransformer(transformer: (query: string) => string) {
this.apiSearchQueryTransformer = transformer;
}

public selectedItem(): HTMLElement | null {
return this.selectedItemElement;
}

selectedValue(): string | null {
return this.selected;
}

selectedAttr(attr: string): string | null {
return this.selectedItemElement
? this.selectedItemElement.querySelector(`[${attr}]`)?.getAttribute(attr) ?? null
: null;
}


public recalculateDirection() {
if (
isEnoughSpace(
Expand Down Expand Up @@ -918,13 +963,13 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {

const preparedItems = isReversed
? Array.from(
output.querySelectorAll(':scope > *:not(.--exclude-accessibility)'),
)
.filter((el) => (el as HTMLElement).style.display !== 'none')
.reverse()
output.querySelectorAll(':scope > *:not(.--exclude-accessibility)'),
)
.filter((el) => (el as HTMLElement).style.display !== 'none')
.reverse()
: Array.from(
output.querySelectorAll(':scope > *:not(.--exclude-accessibility)'),
).filter((el) => (el as HTMLElement).style.display !== 'none');
output.querySelectorAll(':scope > *:not(.--exclude-accessibility)'),
).filter((el) => (el as HTMLElement).style.display !== 'none');
const items = preparedItems.filter(
(el: any) => !el.classList.contains('disabled'),
);
Expand Down Expand Up @@ -1058,7 +1103,7 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
(el) =>
!isParentOrElementHidden(el.element.el) &&
(evt.target as HTMLElement).closest('[data-hs-combo-box]') ===
el.element.el,
el.element.el,
);

const link: HTMLAnchorElement = opened.element.el.querySelector(
Expand All @@ -1080,8 +1125,8 @@ class HSComboBox extends HSBasePlugin<IComboBoxOptions> implements IComboBox {
opened.element.close(
!opened.element.preventSelection
? (evt.target as HTMLElement)
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text')
.querySelector('[data-hs-combo-box-value]')
.getAttribute('data-hs-combo-box-search-text')
: null,
);
}
Expand Down
8 changes: 7 additions & 1 deletion src/plugins/combobox/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface IComboBoxOptions {
apiDataPart?: string | null;
apiQuery?: string | null;
apiSearchQuery?: string | null;
apiSearchQueryTransformer?: QueryTransformer | string | null;
apiHeaders?: {};
apiGroupField?: string | null;
outputItemTemplate?: string | null;
Expand All @@ -24,10 +25,15 @@ export interface IComboBox {

open(): void;
close(): void;
selectedItem(): HTMLElement | null;
selectedValue(): string | null;
selectedAttr(attr: string): string | null;
recalculateDirection(): void;
}

export interface IComboBoxItemAttr {
valueFrom: string;
attr: string;
}
}

export type QueryTransformer = (query: string) => string;