Skip to content
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

feat(common)!: make DOMPurify as optional sanitizer grid option #1503

Merged
merged 4 commits into from
May 3, 2024
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
19 changes: 17 additions & 2 deletions docs/developer-guides/csp-compliance.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
## CSP Compliance
The library is now, at least mostly, CSP (Content Security Policy) compliant since `v4.0`, however there are some exceptions to be aware of. When using any html string as template (for example with Custom Formatter returning an html string), you will not be fully compliant unless you return `TrustedHTML`. You can achieve this by using the `sanitizer` method in combo with [DOMPurify](https://github.com/cure53/DOMPurify) to return `TrustedHTML` as shown below and with that in place you should be CSP compliant.
The library is for the most part CSP (Content Security Policy) compliant since `v4.0` **but** only if you configure the `sanitizer` grid option. DOMPurify used to be included in the project (in version <=4.x) but was made optional in version 5 and higher. The main reason to make it optional is because some users might want to use `dompurify` while others might want to use `isomorphic-dompurify` instead for SSR support (or even no `sanitizer` at all, but that is **not recommended**).

> **Note** the default sanitizer in Slickgrid-Universal is actually already configured to return `TrustedHTML` but the CSP safe in the DataView is opt-in via `useCSPSafeFilter`
> **Note** even if the `sanitizer` is optional, we **strongly suggest** you to configure it as a global grid option and avoid possible XSS attacks from your data and also be CSP compliant. Note that for Salesforce users, you do not have to configure it since Salesforce already use DOMPurify internally.

As mentioned above, the project is mostly CSP compliant, however there are some exceptions to be aware of. When using any html string as template (for example with Custom Formatter returning an html string), you will not be fully compliant unless you return `TrustedHTML`. You can achieve this by using the `sanitizer` method in combo with [DOMPurify](https://github.com/cure53/DOMPurify) to return `TrustedHTML` as shown below and with that in place you should be CSP compliant.

```diff
// prefer the global grid options if possible
this.gridOptions = {
sanitizer: (dirtyHtml) => DOMPurify.sanitize(dirtyHtml, { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true })
};
```

> **Note** If you're wondering about the `ADD_ATTR: ['level']`, well the "level" is a custom attribute used by SlickGrid Grouping/Draggable Grouping to track the grouping level depth and it must be kept.

> **Note** the DataView is not CSP safe by default, it is opt-in via the `useCSPSafeFilter` option.

```typescript
import DOMPurify from 'dompurify';
Expand All @@ -15,7 +28,9 @@ this.gridOptions = {
}
this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, this.gridOptions, this.dataset);
```

with this code in place, we can use the following CSP meta tag (which is what we use in the lib demo, ref: [index.html](https://github.com/ghiscoding/slickgrid-universal/blob/master/examples/vite-demo-vanilla-bundle/index.html#L8-L14))

```html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'nonce-random-string'; require-trusted-types-for 'script'; trusted-types dompurify">
```
Expand Down
17 changes: 16 additions & 1 deletion docs/migrations/migration-to-5.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,19 @@ complexityEditor.collection.push({ value: 9, label: 'Hard' });
complexityEditor.collection.push({ value: 9, label: 'Hard' });
```

if you want to read the Editor class (e.g. `Editors.longText`), you can now reference it via `column.editor.model` or via `column.editorClass`
if you want to read the Editor class (e.g. `Editors.longText`), you can now reference it via `column.editor.model` or via `column.editorClass`

## Grid Functionalities

DOMPurify is now optional via the `sanitizer` grid option and you must now provide it yourself. The main reason to make it optional was because some users might want to use `dompurify` while others might want to use `isomorphic-dompurify` instead for SSR support (or even no `sanitizer` at all, but that is **not recommended**).

> **Note** even if the `sanitizer` is optional, we **strongly suggest** you to configure it as a global grid option and avoid possible XSS attacks from your data and also be CSP compliant. Note that for Salesforce users, you do not have to configure it since Salesforce already use DOMPurify internally.

```diff
// prefer the global grid options if possible
this.gridOptions = {
sanitizer: (dirtyHtml) => DOMPurify.sanitize(dirtyHtml, { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true })
};
```

> **Note** If you're wondering about the `ADD_ATTR: ['level']`, well the "level" is a custom attribute used by SlickGrid Grouping/Draggable Grouping to track the grouping level depth and it must be kept.
2 changes: 1 addition & 1 deletion examples/vite-demo-vanilla-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"@slickgrid-universal/vanilla-bundle": "workspace:~",
"@slickgrid-universal/vanilla-force-bundle": "workspace:~",
"bulma": "^1.0.0",
"dompurify": "^3.1.2",
"fetch-jsonp": "^1.3.0",
"isomorphic-dompurify": "^2.7.0",
"moment-tiny": "^2.30.4",
"multiple-select-vanilla": "^3.1.3",
"rxjs": "^7.8.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { GridOption } from '@slickgrid-universal/common';
import DOMPurify from 'dompurify';

/** Default Grid Options */
export const ExampleGridOptions: GridOption = {
enableSorting: true,
headerRowHeight: 45,
rowHeight: 45,
topPanelHeight: 30
topPanelHeight: 30,
sanitizer: (dirtyHtml) => DOMPurify.sanitize(dirtyHtml, { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true })
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { BindingEventService } from '@slickgrid-universal/binding';
import { ExcelExportService } from '@slickgrid-universal/excel-export';
import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';
import DOMPurify from 'isomorphic-dompurify';
import DOMPurify from 'dompurify';

import { ExampleGridOptions } from './example-grid-options';
import type { TranslateService } from '../translate.service';
Expand Down
1 change: 0 additions & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"autocompleter": "^9.2.1",
"dequal": "^2.0.3",
"excel-builder-vanilla": "3.0.1",
"isomorphic-dompurify": "^2.7.0",
"moment-tiny": "^2.30.4",
"multiple-select-vanilla": "^3.1.3",
"sortablejs": "^1.15.2",
Expand Down
10 changes: 0 additions & 10 deletions packages/common/src/core/__tests__/slickGrid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,16 +811,6 @@ describe('SlickGrid core file', () => {
expect(divElm.outerHTML).toBe('<div><span>only text kept</span></div>');
});

it('should be able to supply differnt sanitizer options to use with DOMPurify before applying html code', () => {
const divElm = document.createElement('div');
const htmlStr = '<span aria-label="some aria label">only text kept</span>';

grid = new SlickGrid<any, Column>('#myGrid', dv, columns, defaultOptions);
grid.applyHtmlCode(divElm, htmlStr, { sanitizerOptions: { ALLOW_ARIA_ATTR: false } });

expect(divElm.outerHTML).toBe('<div><span>only text kept</span></div>');
});

it('should expect HTML string to be kept as a string and not be converted (but html escaped) when "enableHtmlRendering" grid option is disabled', () => {
const divElm = document.createElement('div');
const htmlStr = '<span aria-label="some aria label">only text kept</span>';
Expand Down
34 changes: 15 additions & 19 deletions packages/common/src/core/slickGrid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Sortable, { type SortableEvent } from 'sortablejs';
import DOMPurify from 'isomorphic-dompurify';
import { BindingEventService } from '@slickgrid-universal/binding';
import {
classNameToList,
Expand Down Expand Up @@ -588,22 +587,17 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
return; // same result, just skip it
}

let sanitizedText = val;
if (typeof sanitizedText === 'number' || typeof sanitizedText === 'boolean') {
target.textContent = String(sanitizedText);
if (typeof val === 'number' || typeof val === 'boolean') {
target.textContent = String(val);
} else {
if (typeof this._options?.sanitizer === 'function') {
sanitizedText = this._options.sanitizer(val as string);
} else if (typeof DOMPurify?.sanitize === 'function') {
const purifyOptions = (options?.sanitizerOptions ?? this._options.sanitizerOptions ?? { ADD_ATTR: ['level'], RETURN_TRUSTED_TYPE: true }) as DOMPurify.Config;
sanitizedText = DOMPurify.sanitize(val as string, purifyOptions) as unknown as string;
}
const sanitizedText = this.sanitizeHtmlString(val);

// apply HTML when enableHtmlRendering is enabled but make sure we do have a value (without a value, it will simply use `textContent` to clear text content)
// apply HTML when enableHtmlRendering is enabled
// but make sure we do have a value (without a value, it will simply use `textContent` to clear text content)
if (this._options.enableHtmlRendering && sanitizedText) {
target.innerHTML = sanitizedText;
target.innerHTML = sanitizedText as unknown as string;
} else {
target.textContent = sanitizedText;
target.textContent = sanitizedText as unknown as string;
}
}
}
Expand Down Expand Up @@ -6301,12 +6295,14 @@ export class SlickGrid<TData = any, C extends Column<TData> = Column<TData>, O e
}
}

/** html sanitizer to avoid scripting attack */
sanitizeHtmlString(dirtyHtml: string) {
if (!this._options.sanitizer || typeof dirtyHtml !== 'string') {
return dirtyHtml;
/**
* Sanitize possible dirty html string (remove any potential XSS code like scripts and others) when provided as grid option
* @param dirtyHtml: dirty html string
*/
sanitizeHtmlString<T extends string | TrustedHTML>(dirtyHtml: string): T {
if (typeof this._options?.sanitizer === 'function') {
return this._options.sanitizer(dirtyHtml) as T;
}

return this._options.sanitizer(dirtyHtml);
return dirtyHtml as T;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'jest-extended';
import { Editors } from '../index';
import { AutocompleterEditor } from '../autocompleterEditor';
import { FieldType } from '../../enums/index';
import { AutocompleterOption, Column, ColumnEditor, Editor, EditorArguments, GridOption } from '../../interfaces/index';
import { AutocompleterOption, Column, Editor, EditorArguments, GridOption } from '../../interfaces/index';
import { TranslateServiceStub } from '../../../../../test/translateServiceStub';
import { SlickDataView, SlickEvent, type SlickGrid } from '../../core/index';

Expand Down Expand Up @@ -38,6 +38,7 @@ const gridStub = {
render: jest.fn(),
onBeforeEditCell: new SlickEvent(),
onCompositeEditorChange: new SlickEvent(),
sanitizeHtmlString: (str) => str,
} as unknown as SlickGrid;

describe('AutocompleterEditor', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/common/src/editors/__tests__/dateEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const gridStub = {
render: jest.fn(),
onBeforeEditCell: new SlickEvent(),
onCompositeEditorChange: new SlickEvent(),
sanitizeHtmlString: (str) => str,
} as unknown as SlickGrid;

const gridId = 'grid1';
Expand Down Expand Up @@ -359,7 +360,7 @@ describe('DateEditor', () => {
it('should apply the value to the startDate property with "outputType" format with a field having dot notation (complex object) that passes validation', () => {
mockColumn.editor!.validator = null as any;
mockColumn.type = FieldType.date;
mockColumn.outputType = FieldType.dateTimeIsoAmPm;
mockColumn.outputType = FieldType.dateTimeShortEuro;
mockColumn.field = 'employee.startDate';
mockItemData = { id: 1, employee: { startDate: '2001-04-05T11:33:42.000Z' }, isActive: true };

Expand All @@ -369,7 +370,7 @@ describe('DateEditor', () => {
editor.applyValue(mockItemData, newDate);

// @ts-ignore:2349
expect(mockItemData).toEqual({ id: 1, employee: { startDate: moment(newDate).format('YYYY-MM-DD hh:mm:ss a') }, isActive: true });
expect(mockItemData).toEqual({ id: 1, employee: { startDate: moment(newDate).format('DD/MM/YYYY HH:mm') }, isActive: true });
});

it('should apply the value to the startDate property with output format defined by "saveOutputType" when it passes validation', () => {
Expand Down
34 changes: 3 additions & 31 deletions packages/common/src/editors/__tests__/selectEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const gridStub = {
render: jest.fn(),
onBeforeEditCell: new SlickEvent(),
onCompositeEditorChange: new SlickEvent(),
sanitizeHtmlString: (str) => str,
} as unknown as SlickGrid;

describe('SelectEditor', () => {
Expand Down Expand Up @@ -857,10 +858,10 @@ describe('SelectEditor', () => {
expect(editorListElm[0].innerHTML).toBe('<i class="mdi mdi-check"></i> True');
});

it('should create the multi-select editor with a default search term and have the HTML rendered and sanitized when "enableRenderHtml" is set and has <script> tag', () => {
it('should create the multi-select editor with a default search term and have the HTML rendered when "enableRenderHtml" is set and has <script> tag', () => {
mockColumn.editor = {
enableRenderHtml: true,
collection: [{ isEffort: true, label: 'True', labelPrefix: `<script>alert('test')></script><i class="mdi mdi-check"></i> ` }, { isEffort: false, label: 'False' }],
collection: [{ isEffort: true, label: 'True', labelPrefix: `<i class="mdi mdi-check"></i> ` }, { isEffort: false, label: 'False' }],
collectionOptions: {
separatorBetweenTextLabels: ': ',
includePrefixSuffixToSelectedValues: true,
Expand All @@ -884,35 +885,6 @@ describe('SelectEditor', () => {
expect(editorListElm.length).toBe(2);
expect(editorListElm[0].innerHTML).toBe('<i class="mdi mdi-check"></i> : True');
});

it('should create the multi-select editor with a default search term and have the HTML rendered and sanitized when using a custom "sanitizer" and "enableRenderHtml" flag is set and has <script> tag', () => {
mockColumn.editor = {
enableRenderHtml: true,
collection: [{ isEffort: true, label: 'True', labelPrefix: `<script>alert('test')></script><i class="mdi mdi-check"></i> ` }, { isEffort: false, label: 'False' }],
collectionOptions: {
separatorBetweenTextLabels: ': ',
includePrefixSuffixToSelectedValues: true,
},
customStructure: {
value: 'isEffort',
label: 'label',
labelPrefix: 'labelPrefix',
},
};
mockItemData = { id: 1, gender: 'male', isEffort: false };
gridOptionMock.sanitizer = (dirtyHtml) => dirtyHtml.replace(/(<script>.*?<\/script>)/gi, '');

editor = new SelectEditor(editorArguments, true, 0);
editor.loadValue(mockItemData);
editor.setValue([false]);
const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice') as HTMLButtonElement;
const editorListElm = divContainer.querySelectorAll<HTMLInputElement>(`[data-name=editor-gender].ms-drop ul>li span`);
editorBtnElm.click();

expect(editor.getValue()).toEqual(['']);
expect(editorListElm.length).toBe(2);
expect(editorListElm[0].innerHTML).toBe('<i class="mdi mdi-check"></i> : True');
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const gridStub = {
navigateNext: jest.fn(),
navigatePrev: jest.fn(),
render: jest.fn(),
sanitizeHtmlString: (str) => str,
} as unknown as SlickGrid;

describe('SingleSelectEditor', () => {
Expand Down Expand Up @@ -255,11 +256,11 @@ describe('SingleSelectEditor', () => {
expect(editorListElm[0].innerHTML).toBe('<i class="mdi mdi-check"></i> True');
});

it('should create the multi-select editor with a default value and have the HTML rendered and sanitized when "enableRenderHtml" is set and has <script> tag', () => {
it('should create the multi-select editor with a default value and have the HTML rendered when "enableRenderHtml" is set and has <script> tag', () => {
mockColumn.field = 'isEffort';
mockColumn.editor = {
enableRenderHtml: true,
collection: [{ isEffort: true, label: 'True', labelPrefix: `<script>alert('test')></script><i class="mdi mdi-check"></i> ` }, { isEffort: false, label: 'False' }],
collection: [{ isEffort: true, label: 'True', labelPrefix: `<i class="mdi mdi-check"></i> ` }, { isEffort: false, label: 'False' }],
collectionOptions: {
separatorBetweenTextLabels: ': ',
includePrefixSuffixToSelectedValues: true,
Expand All @@ -281,7 +282,7 @@ describe('SingleSelectEditor', () => {
editorListElm[0].click();

expect(editorBtnElm).toBeTruthy();
expect(editor.getValue()).toEqual(`<script>alert('test')></script><i class="mdi mdi-check"></i> : true`);
expect(editor.getValue()).toEqual(`<i class="mdi mdi-check"></i> : true`);
expect(editorListElm.length).toBe(2);
expect(editorListElm[0].innerHTML).toBe('<i class="mdi mdi-check"></i> : True');
});
Expand Down
3 changes: 1 addition & 2 deletions packages/common/src/editors/autocompleterEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import type {
} from '../interfaces/index';
import { textValidator } from '../editorValidators/textValidator';
import { addAutocompleteLoadingByOverridingFetch } from '../commonEditorFilter';
import { sanitizeTextByAvailableSanitizer, } from '../services/domUtilities';
import { findOrDefault, getDescendantProperty, } from '../services/utilities';
import type { TranslaterService } from '../services/translater.service';
import { SlickEventData, type SlickGrid } from '../core/index';
Expand Down Expand Up @@ -516,7 +515,7 @@ export class AutocompleterEditor<T extends AutocompleteItem = any> implements Ed

// sanitize any unauthorized html tags like script and others
// for the remaining allowed tags we'll permit all attributes
const sanitizedText = sanitizeTextByAvailableSanitizer(this.gridOptions, finalText) || '';
const sanitizedText = this.grid.sanitizeHtmlString<string>(finalText) || '';

const div = document.createElement('div');
div[isRenderHtmlEnabled ? 'innerHTML' : 'textContent'] = sanitizedText;
Expand Down
3 changes: 1 addition & 2 deletions packages/common/src/editors/dateEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { formatDateByFieldType, getDescendantProperty, mapMomentDateFormatWithFi
import type { TranslaterService } from '../services/translater.service';
import { SlickEventData, type SlickGrid } from '../core/index';
import { setPickerDates } from '../commonEditorFilter';
import { sanitizeTextByAvailableSanitizer } from '../services';

/*
* An example of a date picker editor using Vanilla-Calendar-Picker
Expand Down Expand Up @@ -124,7 +123,7 @@ export class DateEditor implements Editor {
const pickerOptions: IOptions = {
input: true,
jumpToSelectedDate: true,
sanitizer: (dirtyHtml) => sanitizeTextByAvailableSanitizer(this.gridOptions, dirtyHtml),
sanitizer: (dirtyHtml) => this.grid.sanitizeHtmlString(dirtyHtml),
toggleSelected: false,
actions: {
clickDay: () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/editors/selectEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
Locale,
SelectOption,
} from './../interfaces/index';
import { buildMsSelectCollectionList, CollectionService, findOrDefault, sanitizeTextByAvailableSanitizer, type TranslaterService } from '../services/index';
import { buildMsSelectCollectionList, CollectionService, findOrDefault, type TranslaterService } from '../services/index';
import { getDescendantProperty, getTranslationPrefix, } from '../services/utilities';
import { SlickEventData, type SlickGrid } from '../core/index';

Expand Down Expand Up @@ -120,7 +120,7 @@ export class SelectEditor implements Editor {
single: true,
singleRadio: true,
renderOptionLabelAsHtml: this.columnEditor?.enableRenderHtml ?? false,
sanitizer: (dirtyHtml: string) => sanitizeTextByAvailableSanitizer(this.gridOptions, dirtyHtml),
sanitizer: (dirtyHtml: string) => this.grid.sanitizeHtmlString(dirtyHtml),
onClick: () => this._isValueTouched = true,
onCheckAll: () => this._isValueTouched = true,
onUncheckAll: () => this._isValueTouched = true,
Expand Down
Loading
Loading