Skip to content

Commit

Permalink
[DataGrid] Escape formulas in CSV and Excel export (mui#13115)
Browse files Browse the repository at this point in the history
  • Loading branch information
cherniavskii authored and DungTiger committed Jul 23, 2024
1 parent 0a3ecc7 commit 4dc5f16
Show file tree
Hide file tree
Showing 15 changed files with 221 additions and 69 deletions.
30 changes: 30 additions & 0 deletions docs/data/data-grid/export/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ For more details on these options, please visit the [`csvOptions` API page](/x/a
/>
```

### Escape formulas

By default, the formulas in the cells are escaped.
This is to prevent the formulas from being executed when [the CSV file is opened in Excel](https://owasp.org/www-community/attacks/CSV_Injection).

If you want to keep the formulas working, you can set the `escapeFormulas` option to `false`.

```jsx
<DataGrid slotProps={{ toolbar: { csvOptions: { escapeFormulas: false } } }} />

// or

<GridToolbarExport csvOptions={{ escapeFormulas: false }} />
```

## Print export

### Modify the data grid style
Expand Down Expand Up @@ -395,6 +410,21 @@ setupExcelExportWebWorker({

:::

### Escape formulas

By default, the formulas in the cells are escaped.
This is to prevent the formulas from being executed when [the file is opened in Excel](https://owasp.org/www-community/attacks/CSV_Injection).

If you want to keep the formulas working, you can set the `escapeFormulas` option to `false`.

```jsx
<DataGridPremium slotProps={{ toolbar: { excelOptions: { escapeFormulas: false } } }} />

// or

<GridToolbarExport excelOptions={{ escapeFormulas: false }} />
```

## Clipboard

The clipboard export allows you to copy the content of the data grid to the clipboard.
Expand Down
1 change: 1 addition & 0 deletions docs/pages/x/api/data-grid/grid-csv-export-options.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"properties": {
"allColumns": { "type": { "description": "boolean" }, "default": "false" },
"delimiter": { "type": { "description": "string" }, "default": "','" },
"escapeFormulas": { "type": { "description": "boolean" }, "default": "true" },
"fields": { "type": { "description": "string[]" } },
"fileName": { "type": { "description": "string" }, "default": "document.title" },
"getRowsToExport": {
Expand Down
5 changes: 5 additions & 0 deletions docs/pages/x/api/data-grid/grid-excel-export-options.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"isPremiumPlan": true
},
"columnsStyles": { "type": { "description": "ColumnsStylesInterface" }, "isPremiumPlan": true },
"escapeFormulas": {
"type": { "description": "boolean" },
"default": "true",
"isPremiumPlan": true
},
"exceljsPostProcess": {
"type": {
"description": "(processInput: GridExceljsProcessInput) =&gt; Promise&lt;void&gt;"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"description": "If <code>true</code>, the hidden columns will also be exported."
},
"delimiter": { "description": "The character used to separate fields." },
"escapeFormulas": {
"description": "If <code>false</code>, the formulas in the cells will not be escaped.<br />It is not recommended to disable this option as it exposes the user to potential CSV injection attacks.<br />See <a href=\"https://owasp.org/www-community/attacks/CSV_Injection\">https://owasp.org/www-community/attacks/CSV_Injection</a> for more information."
},
"fields": {
"description": "The columns exported.<br />This should only be used if you want to restrict the columns exports."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"description": "If <code>true</code>, the hidden columns will also be exported."
},
"columnsStyles": { "description": "Object mapping column field to Exceljs style" },
"escapeFormulas": {
"description": "If <code>false</code>, the formulas in the cells will not be escaped.<br />It is not recommended to disable this option as it exposes the user to potential CSV injection attacks.<br />See <a href=\"https://owasp.org/www-community/attacks/CSV_Injection\">https://owasp.org/www-community/attacks/CSV_Injection</a> for more information."
},
"exceljsPostProcess": {
"description": "Method called after adding the rows to the workbook.<br />Not supported when <code>worker</code> is set.<br />To use with web workers, use the option in <code>setupExcelExportWebWorker</code>."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ GridExcelExportMenuItem.propTypes = {
allColumns: PropTypes.bool,
columnsStyles: PropTypes.object,
disableToolbarButton: PropTypes.bool,
escapeFormulas: PropTypes.bool,
exceljsPostProcess: PropTypes.func,
exceljsPreProcess: PropTypes.func,
fields: PropTypes.arrayOf(PropTypes.string),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,9 +559,12 @@ export const useGridCellSelection = (
if (fieldsMap[field]) {
const cellParams = apiRef.current.getCellParams(rowId, field);
cellData = serializeCellValue(cellParams, {
delimiterCharacter: clipboardCopyCellDelimiter,
csvOptions: {
delimiter: clipboardCopyCellDelimiter,
shouldAppendQuotes: false,
escapeFormulas: false,
},
ignoreValueFormatter,
shouldAppendQuotes: false,
});
} else {
cellData = '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ import {
GridColumnGroupLookup,
isSingleSelectColDef,
} from '@mui/x-data-grid/internals';
import {
GridExceljsProcessInput,
ColumnsStylesInterface,
GridExcelExportOptions,
} from '../gridExcelExportInterface';
import { ColumnsStylesInterface, GridExcelExportOptions } from '../gridExcelExportInterface';
import { GridPrivateApiPremium } from '../../../../models/gridApiPremium';

const getExcelJs = async () => {
Expand Down Expand Up @@ -70,6 +66,7 @@ export const serializeRow = (
columns: GridStateColDef[],
api: GridPrivateApiPremium,
defaultValueOptionsFormulae: { [field: string]: { address: string } },
options: Pick<BuildExcelOptions, 'escapeFormulas'>,
): SerializedRow => {
const row: SerializedRow['row'] = {};
const dataValidation: SerializedRow['dataValidation'] = {};
Expand Down Expand Up @@ -100,6 +97,8 @@ export const serializeRow = (

const cellParams = api.getCellParams(id, column.field);

let cellValue: string | undefined;

switch (cellParams.colDef.type) {
case 'singleSelect': {
const castColumn = cellParams.colDef as GridSingleSelectColDef;
Expand Down Expand Up @@ -152,7 +151,7 @@ export const serializeRow = (
}
case 'boolean':
case 'number':
row[column.field] = api.getCellParams(id, column.field).value as any;
cellValue = api.getCellParams(id, column.field).value as any;
break;
case 'date':
case 'dateTime': {
Expand Down Expand Up @@ -180,14 +179,25 @@ export const serializeRow = (
case 'actions':
break;
default:
row[column.field] = api.getCellParams(id, column.field).formattedValue as any;
cellValue = api.getCellParams(id, column.field).formattedValue as any;
if (process.env.NODE_ENV !== 'production') {
if (String(cellParams.formattedValue) === '[object Object]') {
warnInvalidFormattedValue();
}
}
break;
}

if (typeof cellValue === 'string' && options.escapeFormulas) {
// See https://owasp.org/www-community/attacks/CSV_Injection
if (['=', '+', '-', '@', '\t', '\r'].includes(cellValue[0])) {
cellValue = `'${cellValue}`;
}
}

if (typeof cellValue !== 'undefined') {
row[column.field] = cellValue;
}
});

return {
Expand Down Expand Up @@ -367,14 +377,14 @@ async function createValueOptionsSheetIfNeeded(
});
}

interface BuildExcelOptions {
interface BuildExcelOptions
extends Pick<GridExcelExportOptions, 'exceljsPreProcess' | 'exceljsPostProcess'>,
Pick<
Required<GridExcelExportOptions>,
'valueOptionsSheetName' | 'includeHeaders' | 'includeColumnGroupsHeaders' | 'escapeFormulas'
> {
columns: GridStateColDef[];
rowIds: GridRowId[];
includeHeaders: boolean;
includeColumnGroupsHeaders: boolean;
valueOptionsSheetName: string;
exceljsPreProcess?: (processInput: GridExceljsProcessInput) => Promise<void>;
exceljsPostProcess?: (processInput: GridExceljsProcessInput) => Promise<void>;
columnsStyles?: ColumnsStylesInterface;
}

Expand Down Expand Up @@ -429,7 +439,7 @@ export async function buildExcel(
createValueOptionsSheetIfNeeded(valueOptionsData, valueOptionsSheetName, workbook);

rowIds.forEach((id) => {
const serializedRow = serializeRow(id, columns, api, valueOptionsData);
const serializedRow = serializeRow(id, columns, api, valueOptionsData, options);
addSerializedRowToWorksheet(serializedRow, worksheet);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const useGridExcelExport = (
columnsStyles: options?.columnsStyles,
exceljsPreProcess: options?.exceljsPreProcess,
exceljsPostProcess: options?.exceljsPostProcess,
escapeFormulas: options.escapeFormulas ?? true,
},
apiRef.current,
);
Expand Down Expand Up @@ -141,7 +142,9 @@ export const useGridExcelExport = (
const serializedColumns = serializeColumns(exportedColumns, options.columnsStyles || {});

const serializedRows = exportedRowIds.map((id) =>
serializeRow(id, exportedColumns, apiRef.current, valueOptionsData),
serializeRow(id, exportedColumns, apiRef.current, valueOptionsData, {
escapeFormulas: options.escapeFormulas ?? true,
}),
);

const columnGroupPaths = exportedColumns.reduce<Record<string, string[]>>((acc, column) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,46 @@ describe('<DataGridPremium /> - Export Excel', () => {
expect(worksheet.getCell('B3').type).to.equal(Excel.ValueType.String);
expect(worksheet.getCell('C3').type).to.equal(Excel.ValueType.String);
});

it('should escape formulas in the cells', async () => {
function Test() {
apiRef = useGridApiRef();

return (
<div style={{ width: 300, height: 300 }}>
<DataGridPremium
apiRef={apiRef}
columns={[{ field: 'name' }]}
rows={[
{ id: 0, name: '=1+1' },
{ id: 1, name: '+1+1' },
{ id: 2, name: '-1+1' },
{ id: 3, name: '@1+1' },
{ id: 4, name: '\t1+1' },
{ id: 5, name: '\r1+1' },
{ id: 6, name: ',=1+1' },
{ id: 7, name: 'value,=1+1' },
]}
/>
</div>
);
}

render(<Test />);

const workbook = await apiRef.current.getDataAsExcel();
const worksheet = workbook!.worksheets[0];

expect(worksheet.getCell('A1').value).to.equal('name');
expect(worksheet.getCell('A2').value).to.equal("'=1+1");
expect(worksheet.getCell('A3').value).to.equal("'+1+1");
expect(worksheet.getCell('A4').value).to.equal("'-1+1");
expect(worksheet.getCell('A5').value).to.equal("'@1+1");
expect(worksheet.getCell('A6').value).to.equal("'\t1+1");
expect(worksheet.getCell('A7').value).to.equal("'\r1+1");
expect(worksheet.getCell('A8').value).to.equal(',=1+1');
expect(worksheet.getCell('A9').value).to.equal('value,=1+1');
});
});

describe('web worker', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,21 @@ export const useGridClipboard = (
if (selectedRows.size > 0) {
textToCopy = apiRef.current.getDataAsCsv({
includeHeaders: false,
// TODO: make it configurable
delimiter: clipboardCopyCellDelimiter,
shouldAppendQuotes: false,
escapeFormulas: false,
});
} else {
const focusedCell = gridFocusCellSelector(apiRef);
if (focusedCell) {
const cellParams = apiRef.current.getCellParams(focusedCell.id, focusedCell.field);
textToCopy = serializeCellValue(cellParams, {
delimiterCharacter: clipboardCopyCellDelimiter,
csvOptions: {
delimiter: clipboardCopyCellDelimiter,
shouldAppendQuotes: false,
escapeFormulas: false,
},
ignoreValueFormatter,
shouldAppendQuotes: false,
});
}
}
Expand Down
Loading

0 comments on commit 4dc5f16

Please sign in to comment.