diff --git a/packages/kbn-es-query/src/index.ts b/packages/kbn-es-query/src/index.ts index 3363bd826088f..02d54df995176 100644 --- a/packages/kbn-es-query/src/index.ts +++ b/packages/kbn-es-query/src/index.ts @@ -104,6 +104,7 @@ export { nodeBuilder, nodeTypes, toElasticsearchQuery, + escapeKuery, } from './kuery'; export { diff --git a/packages/kbn-es-query/src/kuery/index.ts b/packages/kbn-es-query/src/kuery/index.ts index 13039956916cb..7e7637e950f91 100644 --- a/packages/kbn-es-query/src/kuery/index.ts +++ b/packages/kbn-es-query/src/kuery/index.ts @@ -23,5 +23,6 @@ export const toElasticsearchQuery = (...params: Parameters { + test('should escape special characters', () => { + const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`; + const expected = `This \\\\ has \\(a lot of\\) \\ characters, don't you \\*think\\*? \\"Yes.\\"`; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should escape keywords', () => { + const value = 'foo and bar or baz not qux'; + const expected = 'foo \\and bar \\or baz \\not qux'; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should escape keywords next to each other', () => { + const value = 'foo and bar or not baz'; + const expected = 'foo \\and bar \\or \\not baz'; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should not escape keywords without surrounding spaces', () => { + const value = 'And this has keywords, or does it not?'; + const expected = 'And this has keywords, \\or does it not?'; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should escape uppercase keywords', () => { + const value = 'foo AND bar'; + const expected = 'foo \\AND bar'; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should escape both keywords and special characters', () => { + const value = 'Hello, world, and to meet you!'; + const expected = 'Hello, world, \\and \\ to meet you!'; + + expect(escapeKuery(value)).toBe(expected); + }); + + test('should escape newlines and tabs', () => { + const value = 'This\nhas\tnewlines\r\nwith\ttabs'; + const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs'; + + expect(escapeKuery(value)).toBe(expected); + }); +}); diff --git a/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts b/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts new file mode 100644 index 0000000000000..6693fbb847fd1 --- /dev/null +++ b/packages/kbn-es-query/src/kuery/utils/escape_kuery.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { flow } from 'lodash'; + +/** + * Escapes a Kuery node value to ensure that special characters, operators, and whitespace do not result in a parsing error or unintended + * behavior when using the value as an argument for the `buildNode` function. + */ +export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); + +// See the SpecialCharacter rule in kuery.peg +function escapeSpecialCharacters(str: string) { + return str.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string +} + +// See the Keyword rule in kuery.peg +function escapeAndOr(str: string) { + return str.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); +} + +function escapeNot(str: string) { + return str.replace(/not(\s+)/gi, '\\$&'); +} + +// See the Space rule in kuery.peg +function escapeWhitespace(str: string) { + return str.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); +} diff --git a/packages/kbn-es-query/src/kuery/utils/index.ts b/packages/kbn-es-query/src/kuery/utils/index.ts new file mode 100644 index 0000000000000..34575ef08573d --- /dev/null +++ b/packages/kbn-es-query/src/kuery/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { escapeKuery } from './escape_kuery'; diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts index 9585c40e6a161..d8c1b8edb9558 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.mock.ts @@ -14,10 +14,7 @@ jest.mock('../../../../elasticsearch', () => { return { getErrorMessage: mockGetEsErrorMessage }; }); -// Mock these functions to return empty results, as this simplifies test cases and we don't need to exercise alternate code paths for these -jest.mock('@kbn/es-query', () => { - return { nodeTypes: { function: { buildNode: jest.fn() } } }; -}); +// Mock this function to return empty results, as this simplifies test cases and we don't need to exercise alternate code paths for these jest.mock('../search_dsl', () => { return { getSearchDsl: jest.fn() }; }); diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts index 7ccacffb9a4d2..e23f8ef1eb9fd 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.test.ts @@ -32,8 +32,9 @@ describe('deleteLegacyUrlAliases', () => { }; } - const type = 'obj-type'; - const id = 'obj-id'; + // Include KQL special characters in the object type/ID to implicitly assert that the kuery node builder handles it gracefully + const type = 'obj-type:"'; + const id = 'id-1:"'; it('throws an error if namespaces includes the "all namespaces" string', async () => { const namespaces = [ALL_NAMESPACES_STRING]; diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts index 4d38afeac6eaa..690465f08bd36 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/delete_legacy_url_aliases.ts @@ -62,11 +62,6 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam return; } - const { buildNode } = esKuery.nodeTypes.function; - const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetType`, type); - const match2 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetId`, id); - const kueryNode = buildNode('and', [match1, match2]); - try { await client.updateByQuery( { @@ -75,7 +70,7 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam body: { ...getSearchDsl(mappings, registry, { type: LEGACY_URL_ALIAS_TYPE, - kueryNode, + kueryNode: createKueryNode(type, id), }), script: { // Intentionally use one script source with variable params to take advantage of ES script caching @@ -107,3 +102,17 @@ export async function deleteLegacyUrlAliases(params: DeleteLegacyUrlAliasesParam function throwError(type: string, id: string, message: string) { throw new Error(`Failed to delete legacy URL aliases for ${type}/${id}: ${message}`); } + +function getKueryKey(attribute: string) { + // Note: these node keys do NOT include '.attributes' for type-level fields because we are using the query in the ES client (instead of the SO client) + return `${LEGACY_URL_ALIAS_TYPE}.${attribute}`; +} + +export function createKueryNode(type: string, id: string) { + const { buildNode } = esKuery.nodeTypes.function; + // Escape Kuery values to prevent parsing errors and unintended behavior (object types/IDs can contain KQL special characters/operators) + const match1 = buildNode('is', getKueryKey('targetType'), esKuery.escapeKuery(type)); + const match2 = buildNode('is', getKueryKey('targetId'), esKuery.escapeKuery(id)); + const kueryNode = buildNode('and', [match1, match2]); + return kueryNode; +} diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts index 755fa5794b575..f0399f4b54aa0 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts @@ -51,7 +51,8 @@ describe('findLegacyUrlAliases', () => { }); } - const obj1 = { type: 'obj-type', id: 'id-1' }; + // Include KQL special characters in the object type/ID to implicitly assert that the kuery node builder handles it gracefully + const obj1 = { type: 'obj-type:"', id: 'id-1:"' }; const obj2 = { type: 'obj-type', id: 'id-2' }; const obj3 = { type: 'obj-type', id: 'id-3' }; diff --git a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts index 7c1ce82129710..70b1730ec8f48 100644 --- a/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts +++ b/src/core/server/saved_objects/service/lib/legacy_url_aliases/find_legacy_url_aliases.ts @@ -68,15 +68,20 @@ export async function findLegacyUrlAliases( function createAliasKueryFilter(objects: Array<{ type: string; id: string }>) { const { buildNode } = esKuery.nodeTypes.function; - // Note: these nodes include '.attributes' for type-level fields because these are eventually passed to `validateConvertFilterToKueryNode`, which requires it const kueryNodes = objects.reduce((acc, { type, id }) => { - const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.targetType`, type); - const match2 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.sourceId`, id); + // Escape Kuery values to prevent parsing errors and unintended behavior (object types/IDs can contain KQL special characters/operators) + const match1 = buildNode('is', getKueryKey('targetType'), esKuery.escapeKuery(type)); + const match2 = buildNode('is', getKueryKey('sourceId'), esKuery.escapeKuery(id)); acc.push(buildNode('and', [match1, match2])); return acc; }, []); return buildNode('and', [ - buildNode('not', buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.disabled`, true)), // ignore aliases that have been disabled + buildNode('not', buildNode('is', getKueryKey('disabled'), true)), // ignore aliases that have been disabled buildNode('or', kueryNodes), ]); } + +function getKueryKey(attribute: string) { + // Note: these node keys include '.attributes' for type-level fields because these are eventually passed to `validateConvertFilterToKueryNode`, which requires it + return `${LEGACY_URL_ALIAS_TYPE}.attributes.${attribute}`; +} diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts index 933449e779ef7..162c461f7a175 100644 --- a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { escapeQuotes, escapeKuery } from './escape_kuery'; +import { escapeQuotes } from './escape_kuery'; describe('Kuery escape', () => { test('should escape quotes', () => { @@ -22,53 +22,4 @@ describe('Kuery escape', () => { expect(escapeQuotes(value)).toBe(expected); }); - - test('should escape special characters', () => { - const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`; - const expected = `This \\\\ has \\(a lot of\\) \\ characters, don't you \\*think\\*? \\"Yes.\\"`; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should escape keywords', () => { - const value = 'foo and bar or baz not qux'; - const expected = 'foo \\and bar \\or baz \\not qux'; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should escape keywords next to each other', () => { - const value = 'foo and bar or not baz'; - const expected = 'foo \\and bar \\or \\not baz'; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should not escape keywords without surrounding spaces', () => { - const value = 'And this has keywords, or does it not?'; - const expected = 'And this has keywords, \\or does it not?'; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should escape uppercase keywords', () => { - const value = 'foo AND bar'; - const expected = 'foo \\AND bar'; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should escape both keywords and special characters', () => { - const value = 'Hello, world, and to meet you!'; - const expected = 'Hello, world, \\and \\ to meet you!'; - - expect(escapeKuery(value)).toBe(expected); - }); - - test('should escape newlines and tabs', () => { - const value = 'This\nhas\tnewlines\r\nwith\ttabs'; - const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs'; - - expect(escapeKuery(value)).toBe(expected); - }); }); diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts index 54f03803a893e..6636f9b602687 100644 --- a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { flow } from 'lodash'; +import { escapeKuery } from '@kbn/es-query'; /** * Escapes backslashes and double-quotes. (Useful when putting a string in quotes to use as a value @@ -16,23 +16,5 @@ export function escapeQuotes(str: string) { return str.replace(/[\\"]/g, '\\$&'); } -export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); - -// See the SpecialCharacter rule in kuery.peg -function escapeSpecialCharacters(str: string) { - return str.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string -} - -// See the Keyword rule in kuery.peg -function escapeAndOr(str: string) { - return str.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); -} - -function escapeNot(str: string) { - return str.replace(/not(\s+)/gi, '\\$&'); -} - -// See the Space rule in kuery.peg -function escapeWhitespace(str: string) { - return str.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); -} +// Re-export this function from the @kbn/es-query package to avoid refactoring +export { escapeKuery };