Skip to content

Commit

Permalink
[Reporting]: Check if CSV cells (including headers) start with known …
Browse files Browse the repository at this point in the history
…formula characters (#37930)

* Re-working csv injection issue into master

* Config flag for checking if CSV's contain formulas

* Fixing snapshots
  • Loading branch information
Joel Griffith authored Jul 1, 2019
1 parent 3e8687c commit 7b67613
Show file tree
Hide file tree
Showing 12 changed files with 289 additions and 2 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,92 @@ describe('CSV Execute Job', function () {
});
});

describe('Cells with formula values', async () => {
it('returns `csv_contains_formulas` when cells contain formulas', async function () {
mockServer.config().get.withArgs('xpack.reporting.csv.checkForFormulas').returns(true);
callWithRequestStub.onFirstCall().returns({
hits: {
hits: [{ _source: { 'one': '=SUM(A1:A2)', 'two': 'bar' } }]
},
_scroll_id: 'scrollId'
});

const executeJob = executeJobFactory(mockServer);
const jobParams = {
headers: encryptedHeaders,
fields: [ 'one', 'two' ],
conflictedTypesFields: [],
searchRequest: { index: null, body: null },
};
const { csv_contains_formulas: csvContainsFormulas } = await executeJob(jobParams, cancellationToken);

expect(csvContainsFormulas).to.equal(true);
});

it('returns warnings when headings contain formulas', async function () {
mockServer.config().get.withArgs('xpack.reporting.csv.checkForFormulas').returns(true);
callWithRequestStub.onFirstCall().returns({
hits: {
hits: [{ _source: { '=SUM(A1:A2)': 'foo', 'two': 'bar' } }]
},
_scroll_id: 'scrollId'
});

const executeJob = executeJobFactory(mockServer);
const jobParams = {
headers: encryptedHeaders,
fields: [ '=SUM(A1:A2)', 'two' ],
conflictedTypesFields: [],
searchRequest: { index: null, body: null },
};
const { csv_contains_formulas: csvContainsFormulas } = await executeJob(jobParams, cancellationToken);

expect(csvContainsFormulas).to.equal(true);
});

it('returns no warnings when cells have no formulas', async function () {
mockServer.config().get.withArgs('xpack.reporting.csv.checkForFormulas').returns(true);
callWithRequestStub.onFirstCall().returns({
hits: {
hits: [{ _source: { 'one': 'foo', 'two': 'bar' } }]
},
_scroll_id: 'scrollId'
});

const executeJob = executeJobFactory(mockServer);
const jobParams = {
headers: encryptedHeaders,
fields: [ 'one', 'two' ],
conflictedTypesFields: [],
searchRequest: { index: null, body: null },
};
const { csv_contains_formulas: csvContainsFormulas } = await executeJob(jobParams, cancellationToken);

expect(csvContainsFormulas).to.equal(false);
});

it('returns no warnings when configured not to', async () => {
mockServer.config().get.withArgs('xpack.reporting.csv.checkForFormulas').returns(false);
callWithRequestStub.onFirstCall().returns({
hits: {
hits: [{ _source: { 'one': '=SUM(A1:A2)', 'two': 'bar' } }]
},
_scroll_id: 'scrollId'
});

const executeJob = executeJobFactory(mockServer);
const jobParams = {
headers: encryptedHeaders,
fields: [ 'one', 'two' ],
conflictedTypesFields: [],
searchRequest: { index: null, body: null },
};
const { csv_contains_formulas: csvContainsFormulas } = await executeJob(jobParams, cancellationToken);

expect(csvContainsFormulas).to.equal(false);
});
});

describe('Elasticsearch call errors', function () {
it('should reject Promise if search call errors out', async function () {
callWithRequestStub.rejects(new Error());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function executeJobFn(server) {
})(),
]);

const { content, maxSizeReached, size } = await generateCsv({
const { content, maxSizeReached, size, csvContainsFormulas } = await generateCsv({
searchRequest,
fields,
metaFields,
Expand All @@ -102,6 +102,7 @@ function executeJobFn(server) {
formatsMap,
settings: {
...uiSettings,
checkForFormulas: config.get('xpack.reporting.csv.checkForFormulas'),
maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'),
scroll: config.get('xpack.reporting.csv.scroll'),
},
Expand All @@ -112,6 +113,7 @@ function executeJobFn(server) {
content,
max_size_reached: maxSizeReached,
size,
csv_contains_formulas: csvContainsFormulas,
};
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { checkIfRowsHaveFormulas } from './check_cells_for_formulas';

const formulaValues = ['=', '+', '-', '@'];
const nonRows = [null, undefined, 9, () => {}];

describe(`Check CSV Injected values`, () => {
it(`returns 'false' when there's no formula values in cells`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
value: 'cool',
title: 'nice',
},
['_doc', 'value', 'title']
)
).toBe(false);
});

formulaValues.forEach(formula => {
it(`returns 'true' when cells start with "${formula}"`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
value: formula,
title: 'nice',
},
['_doc', 'value', 'title']
)
).toBe(true);
});

it(`returns 'false' when cells start with "${formula}" but aren't selected`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
value: formula,
title: 'nice',
},
['_doc', 'title']
)
).toBe(false);
});
});

formulaValues.forEach(formula => {
it(`returns 'true' when headers start with "${formula}"`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
[formula]: 'baz',
title: 'nice',
},
['_doc', formula, 'title']
)
).toBe(true);
});

it(`returns 'false' when headers start with "${formula}" but aren't selected in fields`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
[formula]: 'baz',
title: 'nice',
},
['_doc', 'title']
)
).toBe(false);
});
});

nonRows.forEach(nonRow => {
it(`returns false when there's "${nonRow}" for rows`, () => {
expect(
checkIfRowsHaveFormulas(
{
_doc: 'foo-bar',
// @ts-ignore need to assert non-string values still return false
value: nonRow,
title: 'nice',
},
['_doc', 'value', 'title']
)
).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import * as _ from 'lodash';

const formulaValues = ['=', '+', '-', '@'];

interface IFlattened {
[header: string]: string;
}

export const checkIfRowsHaveFormulas = (flattened: IFlattened, fields: string[]) => {
const pruned = _.pick(flattened, fields);
const csvValues = [..._.keys(pruned), ...(_.values(pruned) as string[])];

return _.some(csvValues, cell => _.some(formulaValues, char => _.startsWith(cell, char)));
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createFormatCsvValues } from './format_csv_values';
import { createEscapeValue } from './escape_value';
import { createHitIterator } from './hit_iterator';
import { MaxSizeStringBuilder } from './max_size_string_builder';
import { checkIfRowsHaveFormulas } from './check_cells_for_formulas';

export function createGenerateCsv(logger) {
const hitIterator = createHitIterator(logger);
Expand All @@ -35,6 +36,7 @@ export function createGenerateCsv(logger) {

const iterator = hitIterator(settings.scroll, callEndpoint, searchRequest, cancellationToken);
let maxSizeReached = false;
let csvContainsFormulas = false;

const flattenHit = createFlattenHit(fields, metaFields, conflictedTypesFields);
const formatCsvValues = createFormatCsvValues(escapeValue, settings.separator, fields, formatsMap);
Expand All @@ -46,7 +48,15 @@ export function createGenerateCsv(logger) {
break;
}

if (!builder.tryAppend(formatCsvValues(flattenHit(hit)) + '\n')) {
const flattened = flattenHit(hit);
const rows = formatCsvValues(flattened);
const rowsHaveFormulas = settings.checkForFormulas && checkIfRowsHaveFormulas(flattened, fields);

if (rowsHaveFormulas) {
csvContainsFormulas = true;
}

if (!builder.tryAppend(rows + '\n')) {
logger.warn('max Size Reached');
maxSizeReached = true;
cancellationToken.cancel();
Expand All @@ -61,6 +71,7 @@ export function createGenerateCsv(logger) {

return {
content: builder.getString(),
csvContainsFormulas,
maxSizeReached,
size,
};
Expand Down
1 change: 1 addition & 0 deletions x-pack/legacy/plugins/reporting/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const reporting = (kibana) => {
}).default()
}).default(),
csv: Joi.object({
checkForFormulas: Joi.boolean().default(true),
enablePanelActionDownload: Joi.boolean().default(false),
maxSizeBytes: Joi.number().integer().default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10
scroll: Joi.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface Job {
max_size_reached: boolean;
attempts: number;
max_attempts: number;
csv_contains_formulas: boolean;
}

interface Props {
Expand Down Expand Up @@ -334,6 +335,21 @@ class ReportListingUi extends Component<Props, State> {
/>
);

if (record.csv_contains_formulas) {
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.csvContainsFormulas',
defaultMessage:
'Your CSV contains characters which spreadsheet applications can interpret as formulas.',
})}
>
{button}
</EuiToolTip>
);
}

if (record.max_size_reached) {
return (
<EuiToolTip
Expand Down Expand Up @@ -428,6 +444,7 @@ class ReportListingUi extends Component<Props, State> {
max_size_reached: job._source.output ? job._source.output.max_size_reached : false,
attempts: job._source.attempts,
max_attempts: job._source.max_attempts,
csv_contains_formulas: job._source.output.csv_contains_formulas,
})
),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,32 @@ uiModules.get('kibana')
);

const maxSizeReached = get(job, '_source.output.max_size_reached');
const csvContainsFormulas = get(job, '_source.output.csv_contains_formulas');

if (csvContainsFormulas) {
return toastNotifications.addWarning({
title: (
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.csvContainsFormulas.formulaReportTitle"
defaultMessage="Report may contain formulas {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType, reportObjectTitle }}
/>
),
text: (
<div>
<p>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.csvContainsFormulas.formulaReportMessage"
defaultMessage="The report contains characters which spreadsheet applications can interpret as formulas."
/>
</p>
{seeReportLink}
{downloadReportButton}
</div>
),
'data-test-subj': 'completeReportSuccess',
});
}

if (maxSizeReached) {
return toastNotifications.addWarning({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export class Worker extends events.EventEmitter {
docOutput.content_type = output.content_type || unknownMime;
docOutput.max_size_reached = output.max_size_reached;
docOutput.size = output.size;
docOutput.csv_contains_formulas = output.csv_contains_formulas;
} else {
docOutput.content = output || defaultOutput;
docOutput.content_type = unknownMime;
Expand Down
Loading

0 comments on commit 7b67613

Please sign in to comment.