diff --git a/.eslintrc.js b/.eslintrc.js index 3e07d34cb15b2..a5643f5ea8742 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -806,6 +806,7 @@ module.exports = { { files: ['**/*.{js,mjs,ts,tsx}'], rules: { + '@kbn/eslint/no_unsafe_dynamic_http_path': 'warn', 'no-restricted-imports': ['error', ...RESTRICTED_IMPORTS], 'no-restricted-modules': [ 'error', diff --git a/examples/routing_example/public/services.ts b/examples/routing_example/public/services.ts index ac6bfbc5f1690..35de512a7c6e1 100644 --- a/examples/routing_example/public/services.ts +++ b/examples/routing_example/public/services.ts @@ -14,7 +14,7 @@ import type { ThemeServiceStart, UserProfileService, } from '@kbn/core/public'; -import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { buildPath, type IHttpFetchError } from '@kbn/core-http-browser'; import { RANDOM_NUMBER_ROUTE_PATH, RANDOM_NUMBER_BETWEEN_ROUTE_PATH, @@ -66,7 +66,7 @@ export function getServices(core: CoreStart): Services { }, postMessage: async (message: string, id: string) => { try { - await core.http.post(`${POST_MESSAGE_ROUTE_PATH}/${id}`, { + await core.http.post(buildPath(`${POST_MESSAGE_ROUTE_PATH}/{id}`, { id }), { body: JSON.stringify({ message }), }); } catch (e) { diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index 67fbed024b3fa..cdab3d8474c25 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -18,6 +18,7 @@ module.exports = { no_trailing_import_slash: require('./rules/no_trailing_import_slash'), no_constructor_args_in_property_initializers: require('./rules/no_constructor_args_in_property_initializers'), no_this_in_property_initializers: require('./rules/no_this_in_property_initializers'), + no_unsafe_dynamic_http_path: require('./rules/no_unsafe_dynamic_http_path'), no_unsafe_console: require('./rules/no_unsafe_console'), no_unsafe_hash: require('./rules/no_unsafe_hash'), scout_no_describe_configure: require('./rules/scout_no_describe_configure'), diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.js b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.js new file mode 100644 index 0000000000000..ad37a9ddba67b --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.js @@ -0,0 +1,311 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const tsEstree = require('@typescript-eslint/typescript-estree'); +const esTypes = tsEstree.AST_NODE_TYPES; + +/** @typedef {import("eslint").Rule.RuleModule} Rule */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.CallExpression} CallExpression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Expression} Expression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.MemberExpression} MemberExpression */ +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Node} Node */ + +const HTTP_REQUEST_METHOD_NAMES = new Set([ + 'delete', + 'fetch', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', +]); +const WARN_MSG = + 'Dangerous use of dynamic http path. Use buildPath() from `@kbn/core-http-browser` or encodeURIComponent() so path params are encoded safely.'; +const ALL_CAPS_IDENTIFIER_PATTERN = /^[A-Z][A-Z0-9_]*$/; + +/** + * Limitations: + * - This rule only inspects inline path expressions passed directly as the first argument to `http` request calls, + * or inline `path` properties in the object overload (for example `http.get({ path: ... })`). + * - It does not perform data-flow analysis, so `const path = \`/api/${id}\`; http.delete(path)` is not flagged. + * - It only targets standard HTTP verb calls and `http.fetch(...)` on `http`-like receivers + * (`http`, `this.http`, `foo.http`, etc.). + * - It treats `encodeURIComponent(...)` as a safe escape hatch, but it does not verify that callers are + * encoding the correct path segment or using the best API shape for the route. + */ + +/** + * @param {Node} node + * @returns {node is MemberExpression} + */ +const isMemberExpression = (node) => node.type === esTypes.MemberExpression; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isHttpReference = (node) => { + if (node.type === esTypes.Identifier) { + return node.name === 'http'; + } + + if (!isMemberExpression(node) || node.computed || node.property.type !== esTypes.Identifier) { + return false; + } + + if (node.property.name === 'http') { + return true; + } + + return isHttpReference(node.object); +}; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isUsingEncodeURIComponent = (node) => { + if (node.type === esTypes.CallExpression) { + const { callee } = node; + + if (callee.type === esTypes.Identifier) { + return callee.name === 'encodeURIComponent'; + } + + return ( + callee.type === esTypes.MemberExpression && + !callee.computed && + callee.property.type === esTypes.Identifier && + callee.property.name === 'encodeURIComponent' + ); + } + + if (node.type === esTypes.BinaryExpression && node.operator === '+') { + return isUsingEncodeURIComponent(node.left) && isUsingEncodeURIComponent(node.right); + } + + if (node.type === esTypes.ConditionalExpression) { + return isUsingEncodeURIComponent(node.consequent) && isUsingEncodeURIComponent(node.alternate); + } + + return false; +}; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isAllCapsIdentifier = (node) => { + return node.type === esTypes.Identifier && ALL_CAPS_IDENTIFIER_PATTERN.test(node.name); +}; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isSafePathPrefixReference = (node) => { + if (isAllCapsIdentifier(node)) { + return true; + } + + if ( + node.type !== esTypes.MemberExpression || + node.computed || + node.property.type !== esTypes.Identifier + ) { + return false; + } + + return isSafePathPrefixReference(node.object); +}; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isPathExpressionStartingWithSlash = (node) => { + if (node.type === esTypes.Literal) { + return typeof node.value === 'string' && node.value.startsWith('/'); + } + + if (node.type === esTypes.TemplateLiteral) { + const cooked = node.quasis[0].value.cooked; + return typeof cooked === 'string' && cooked.startsWith('/'); + } + + if (node.type === esTypes.BinaryExpression && node.operator === '+') { + return isPathExpressionStartingWithSlash(node.left); + } + + if (node.type === esTypes.ConditionalExpression) { + return ( + isPathExpressionStartingWithSlash(node.consequent) && + isPathExpressionStartingWithSlash(node.alternate) + ); + } + + return false; +}; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isSafePathSegmentExpression = (node) => { + if (node.type === esTypes.Literal) { + return true; + } + + if (isUsingEncodeURIComponent(node)) { + return true; + } + + if (node.type === esTypes.TemplateLiteral) { + return isSafeTemplateLiteralPath(node); + } + + if (node.type === esTypes.BinaryExpression && node.operator === '+') { + return ( + (isSafePathSegmentExpression(node.left) && isSafePathSegmentExpression(node.right)) || + (isSafePathPrefixReference(node.left) && + isPathExpressionStartingWithSlash(node.right) && + isSafePathSegmentExpression(node.right)) + ); + } + + if (node.type === esTypes.ConditionalExpression) { + return ( + isSafePathSegmentExpression(node.consequent) && isSafePathSegmentExpression(node.alternate) + ); + } + + return false; +}; + +/** + * @param {import("@typescript-eslint/typescript-estree").TSESTree.TemplateLiteral} node + * @returns {boolean} + */ +function isSafeTemplateLiteralPath(node) { + return node.expressions.every((expression, index) => { + if (isSafePathSegmentExpression(expression)) { + return true; + } + + const afterExpr = node.quasis[1]?.value.cooked; + return ( + index === 0 && + isSafePathPrefixReference(expression) && + node.quasis[0].value.cooked === '' && + typeof afterExpr === 'string' && + afterExpr.startsWith('/') + ); + }); +} + +/** + * @param {Expression} node + * @returns {boolean} + */ +const isDynamicPathExpression = (node) => { + if (node.type === esTypes.TemplateLiteral) { + return !isSafePathSegmentExpression(node); + } + + if (node.type === esTypes.BinaryExpression && node.operator === '+') { + return !isSafePathSegmentExpression(node); + } + + if (node.type === esTypes.ConditionalExpression) { + return !isSafePathSegmentExpression(node); + } + + return false; +}; + +/** + * @param {CallExpression} node + * @returns {boolean} + */ +const isHttpRequestCall = (node) => { + if ( + node.callee.type !== esTypes.MemberExpression || + node.callee.computed || + node.callee.property.type !== esTypes.Identifier || + !HTTP_REQUEST_METHOD_NAMES.has(node.callee.property.name) + ) { + return false; + } + + return isHttpReference(node.callee.object); +}; + +/** + * @param {Expression | import("@typescript-eslint/typescript-estree").TSESTree.SpreadElement} node + * @returns {Expression | undefined} + */ +const getPathExpression = (node) => { + if (node.type === esTypes.SpreadElement) { + return undefined; + } + + if (node.type !== esTypes.ObjectExpression) { + return node; + } + + for (const property of node.properties) { + if (property.type !== esTypes.Property || property.computed || property.kind !== 'init') { + continue; + } + + const isPathKey = + (property.key.type === esTypes.Identifier && property.key.name === 'path') || + (property.key.type === esTypes.Literal && property.key.value === 'path'); + + if (isPathKey) { + return property.value; + } + } + + return undefined; +}; + +/** @type {Rule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow dynamically building inline paths in http request calls so callers use buildPath instead', + }, + schema: [], + }, + create(context) { + return { + CallExpression(_) { + const node = /** @type {CallExpression} */ (_); + if (!isHttpRequestCall(node) || node.arguments.length === 0) { + return; + } + + const [firstArgument] = node.arguments; + const pathExpression = getPathExpression(firstArgument); + if (!pathExpression || !isDynamicPathExpression(pathExpression)) { + return; + } + + context.report({ + node: pathExpression, + message: WARN_MSG, + }); + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.test.js b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.test.js new file mode 100644 index 0000000000000..8dbdc7221bedf --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/no_unsafe_dynamic_http_path.test.js @@ -0,0 +1,171 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const { RuleTester } = require('eslint'); +const dedent = require('dedent'); +const rule = require('./no_unsafe_dynamic_http_path'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, +}); + +const WARN_MSG = + 'Dangerous use of dynamic http path. Use buildPath() from `@kbn/core-http-browser` or encodeURIComponent() so path params are encoded safely.'; + +ruleTester.run('@kbn/eslint/no_unsafe_dynamic_http_path', rule, { + valid: [ + { + code: dedent` + http.delete('/api/dashboards/123'); + `, + }, + { + code: dedent` + http.delete(buildPath('/api/dashboards/{id}', { id })); + `, + }, + { + code: ['const path = `/api/dashboards/${id}`;', 'http.delete(path);'].join('\n'), + }, + { + code: 'http.post(`/api/dashboards/${encodeURIComponent(id)}`);', + }, + { + code: dedent` + client.delete(\`/api/dashboards/\${id}\`); + `, + }, + { + code: 'http.delete(`/api/dashboards/${encodeURIComponent(id)}`);', + }, + { + code: dedent` + http.delete('/api/dashboards/' + encodeURIComponent(id)); + `, + }, + { + code: dedent` + http.get({ path: buildPath('/api/dashboards/{id}', { id }) }); + `, + }, + { + code: dedent` + http.put({ path: '/api/dashboards/' + encodeURIComponent(id) }); + `, + }, + { + code: dedent` + http.patch({ 'path': '/api/dashboards/' + encodeURIComponent(id), body }); + `, + }, + { + code: dedent` + http.fetch(buildPath('/api/dashboards/{id}', { id }), { method: 'DELETE' }); + `, + }, + { + code: dedent` + http.fetch({ path: buildPath('/api/dashboards/{id}', { id }), method: 'POST', body }); + `, + }, + { + code: dedent` + http.fetch({ body: '/api/dashboards/' + id, method: 'POST' }); + `, + }, + { + code: [ + 'return await this.http.delete(', + ' `${INTERNAL_ROUTES.JOBS.DELETE_PREFIX}/${encodeURIComponent(jobId)}`', + ');', + ].join('\n'), + }, + { + code: dedent` + return await this.http.delete( + INTERNAL_ROUTES.JOBS.DELETE_PREFIX + '/' + encodeURIComponent(jobId) + ); + `, + }, + { + code: 'http.get({ path: `${MY_CONSTANT.path}/${encodeURIComponent(id)}` });', + }, + ], + invalid: [ + { + code: 'http.delete(`/api/dashboards/${id}`);', + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: 'this.http.get(`/api/dashboards/${id}`);', + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: dedent` + Legacy.shims.http.post('/api/dashboards/' + id); + `, + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: dedent` + getServices().http.put({ path: basePath + '/' + id }); + `, + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: "http.options(condition ? `/api/dashboards/${id}` : '/api/dashboards/default');", + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: "http.head({ path: condition ? `/api/dashboards/${id}` : '/api/dashboards/default' });", + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: dedent` + http.patch({ 'path': '/api/dashboards/' + id, body }); + `, + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: dedent` + http.fetch('/api/dashboards/' + id, { method: 'DELETE' }); + `, + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: "this.http.fetch({ path: `/api/dashboards/${id}`, method: 'POST', body });", + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: 'this.http.delete(`${INTERNAL_ROUTES.JOBS.DELETE_PREFIX}/${jobId}`);', + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: dedent` + this.http.delete(INTERNAL_ROUTES.JOBS.DELETE_PREFIX + '/' + jobId); + `, + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: 'this.http.get(`${prefix}/${encodeURIComponent(id)}`);', + errors: [{ line: 1, message: WARN_MSG }], + }, + { + code: 'this.http.get(`${MY_CONSTANT.path}/${MY_CONSTANT.path2}`);', + errors: [{ line: 1, message: WARN_MSG }], + }, + ], +}); diff --git a/src/core/packages/http/browser/index.ts b/src/core/packages/http/browser/index.ts index 142ac7ff1915d..6b16bdb44d753 100644 --- a/src/core/packages/http/browser/index.ts +++ b/src/core/packages/http/browser/index.ts @@ -30,4 +30,4 @@ export type { IHttpInterceptController, } from './src/types'; -export { isHttpFetchError } from './src/utils'; +export { buildPath, isHttpFetchError } from './src/utils'; diff --git a/src/core/packages/http/browser/src/utils.test.ts b/src/core/packages/http/browser/src/utils.test.ts new file mode 100644 index 0000000000000..6265e9f8b2542 --- /dev/null +++ b/src/core/packages/http/browser/src/utils.test.ts @@ -0,0 +1,82 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { buildPath } from './utils'; + +describe('buildPath', () => { + it('encodes required path parameters', () => { + expect( + buildPath('/api/myapi/{id}/{name?}', { + id: '../../test', + }) + ).toBe('/api/myapi/..%2F..%2Ftest'); + }); + + it('encodes two required path parameters', () => { + expect( + buildPath('/api/myapi/{id}/{name}', { + id: 'hello//', + name: '//world', + }) + ).toBe('/api/myapi/hello%2F%2F/%2F%2Fworld'); + }); + + it('adds optional path segments when the parameter is present', () => { + expect(buildPath('/api/myapi/{section}/{id?}', { section: 'test', id: 'tada' })).toBe( + '/api/myapi/test/tada' + ); + }); + + it('removes optional path segments when the parameter is missing', () => { + expect(buildPath('/api/myapi/{section}/{id?}', { section: 'test' })).toBe('/api/myapi/test'); + }); + + it('removes optional path segments when the parameter is undefined', () => { + const params: { section: string; id: string | undefined } = { + section: 'test', + id: undefined, + }; + + expect(buildPath('/api/myapi/{section}/{id?}', params)).toBe('/api/myapi/test'); + }); + + it('encodes multi-segment path parameters', () => { + expect( + buildPath('/api/myapi/{filePath*}', { + filePath: 'nested/folder/my file.txt', + }) + ).toBe('/api/myapi/nested/folder/my%20file.txt'); + }); + + it('encodes counted multi-segment path parameters from arrays', () => { + expect( + buildPath('/api/myapi/{coordinates*2}', { + coordinates: ['north east', 'south/west'], + }) + ).toBe('/api/myapi/north%20east/south%2Fwest'); + }); + + it('throws when a counted multi-segment path parameter has the wrong number of segments', () => { + expect(() => buildPath('/api/myapi/{coordinates*2}', { coordinates: 'only-one' })).toThrow( + 'Expected 2 path segment(s) for parameter: coordinates, received 1' + ); + }); + + it('throws when unsupported path template syntax remains unresolved', () => { + expect(() => buildPath('/api/myapi/{filePath?*}', { filePath: 'nested/file.txt' })).toThrow( + 'Unsupported path template syntax: {filePath?*}' + ); + }); + + it('throws when a required path parameter is missing', () => { + expect(() => buildPath('/api/dashboards/{id}', {})).toThrow( + 'Missing required path parameter: id' + ); + }); +}); diff --git a/src/core/packages/http/browser/src/utils.ts b/src/core/packages/http/browser/src/utils.ts index c037861748856..0859679b1a259 100644 --- a/src/core/packages/http/browser/src/utils.ts +++ b/src/core/packages/http/browser/src/utils.ts @@ -13,3 +13,89 @@ import type { IHttpFetchError } from './types'; export function isHttpFetchError(error: T | IHttpFetchError): error is IHttpFetchError { return error instanceof Error && 'request' in error && 'name' in error; } + +type HttpPathParamPrimitive = string | number | boolean; +type HttpPathParamValue = HttpPathParamPrimitive | readonly HttpPathParamPrimitive[]; +type HttpPathParams = Record; + +const OPTIONAL_PATH_SEGMENT_REGEX = /\/\{(\w+)\?\}/g; +const REQUIRED_PATH_PARAM_REGEX = /\{(\w+)(\*(\d*))?\}/g; +const UNSUPPORTED_PATH_TEMPLATE_REGEX = /\{[^}]+\}/; + +const encodePathSegment = (value: HttpPathParamPrimitive) => encodeURIComponent(String(value)); + +const getMultiSegmentValues = (value: HttpPathParamValue) => + Array.isArray(value) ? value : String(value).split('/'); + +const serializePathParam = ( + paramName: string, + value: HttpPathParamValue, + segmentCount?: number +) => { + if (segmentCount == null) { + if (Array.isArray(value)) { + throw new Error(`Expected a single path segment for parameter: ${paramName}`); + } + + return encodePathSegment(value as HttpPathParamPrimitive); + } + + // Wildcard params represent multiple path segments, so string inputs are split on `/` + // before each segment is encoded and re-joined. + const segments = getMultiSegmentValues(value); + + if (segmentCount > -1 && segments.length !== segmentCount) { + throw new Error( + `Expected ${segmentCount} path segment(s) for parameter: ${paramName}, received ${segments.length}` + ); + } + + return segments.map(encodePathSegment).join('/'); +}; +/** + * Builds a URL path from a route template by URI-encoding path params. + * + * @example + * buildPath('/api/dashboards/{id}', { id: '../../../internal/security/users/foo' }); + * // '/api/dashboards/..%2F..%2F..%2Finternal%2Fsecurity%2Fusers%2Ffoo' + * + * @example + * buildPath('/api/files/{filePath*}', { filePath: 'nested/folder/my file.txt' }); + * // '/api/files/nested/folder/my%20file.txt' + * + * @public + */ +export function buildPath(path: string, params: HttpPathParams = {}) { + const pathWithOptionalSegments = path.replace(OPTIONAL_PATH_SEGMENT_REGEX, (match, paramName) => { + const value = params[paramName]; + return value == null ? '' : `/${serializePathParam(paramName, value)}`; + }); + + const builtPath = pathWithOptionalSegments.replace( + REQUIRED_PATH_PARAM_REGEX, + (match, paramName, multiSegmentMatch, segmentCountMatch) => { + const value = params[paramName]; + + if (value == null) { + throw new Error(`Missing required path parameter: ${paramName}`); + } + + const segmentCount = + multiSegmentMatch == null + ? undefined + : segmentCountMatch === '' + ? -1 + : Number(segmentCountMatch); + + return serializePathParam(paramName, value, segmentCount); + } + ); + + const unsupportedToken = builtPath.match(UNSUPPORTED_PATH_TEMPLATE_REGEX)?.[0]; + + if (unsupportedToken) { + throw new Error(`Unsupported path template syntax: ${unsupportedToken}`); + } + + return builtPath; +} diff --git a/src/platform/packages/shared/content-management/favorites/favorites_public/src/favorites_client.ts b/src/platform/packages/shared/content-management/favorites/favorites_public/src/favorites_client.ts index c8a9fa5502553..e82140c31b536 100644 --- a/src/platform/packages/shared/content-management/favorites/favorites_public/src/favorites_client.ts +++ b/src/platform/packages/shared/content-management/favorites/favorites_public/src/favorites_client.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { HttpStart } from '@kbn/core-http-browser'; +import { type HttpStart } from '@kbn/core-http-browser'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser'; import type { diff --git a/src/platform/packages/shared/home/sample_data_card/src/services.tsx b/src/platform/packages/shared/home/sample_data_card/src/services.tsx index 8b15385c3aaa9..5fa593f4a9e4f 100644 --- a/src/platform/packages/shared/home/sample_data_card/src/services.tsx +++ b/src/platform/packages/shared/home/sample_data_card/src/services.tsx @@ -120,7 +120,7 @@ export const SampleDataCardKibanaProvider: FC { - await http.post(`${SAMPLE_DATA_API}/${id}`); + await http.post(`${SAMPLE_DATA_API}/${encodeURIComponent(id)}`); if (uiSettings.isDefault('defaultIndex')) { uiSettings.set('defaultIndex', defaultIndex); @@ -129,7 +129,7 @@ export const SampleDataCardKibanaProvider: FC { - await http.delete(`${SAMPLE_DATA_API}/${id}`); + await http.delete(`${SAMPLE_DATA_API}/${encodeURIComponent(id)}`); if ( !uiSettings.isDefault('defaultIndex') && diff --git a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts index ca8667860008b..b28e090e42427 100644 --- a/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/platform/plugins/shared/data/public/search/search_interceptor/search_interceptor.ts @@ -51,6 +51,7 @@ import type { } from '@kbn/core/public'; import { BatchedFunc, BfetchPublicSetup, DISABLE_BFETCH } from '@kbn/bfetch-plugin/public'; +import { buildPath } from '@kbn/core-http-browser'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { AbortError, KibanaServerError } from '@kbn/kibana-utils-plugin/public'; import { BfetchRequestError } from '@kbn/bfetch-error'; @@ -370,7 +371,15 @@ export class SearchInterceptor { }); const sendCancelRequest = once(() => - this.deps.http.delete(`/internal/search/${strategy}/${id}`, { version: '1' }) + this.deps.http.delete( + buildPath('/internal/search/{strategy}/{id}', { + strategy: `${strategy}`, + id: id as string, + }), + { + version: '1', + } + ) ); const cancel = async () => { @@ -469,7 +478,10 @@ export class SearchInterceptor { const { executionContext, strategy, ...searchOptions } = this.getSerializableOptions(options); return this.deps.http .post( - `/internal/search/${strategy}${request.id ? `/${request.id}` : ''}`, + buildPath('/internal/search/{strategy}/{id?}', { + strategy: `${strategy}`, + id: request.id, + }), { version: '1', signal: abortSignal, diff --git a/src/platform/plugins/shared/home/moon.yml b/src/platform/plugins/shared/home/moon.yml index 95c9f7ff9b225..468f3eea0f732 100644 --- a/src/platform/plugins/shared/home/moon.yml +++ b/src/platform/plugins/shared/home/moon.yml @@ -46,6 +46,7 @@ dependsOn: - '@kbn/shared-ux-link-redirect-app' - '@kbn/deeplinks-observability' - '@kbn/react-kibana-context-theme' + - '@kbn/core-http-browser' tags: - plugin - prod diff --git a/src/platform/plugins/shared/home/public/application/sample_data_client.ts b/src/platform/plugins/shared/home/public/application/sample_data_client.ts index 15a0e92e18ca4..49cb5d65ab02b 100644 --- a/src/platform/plugins/shared/home/public/application/sample_data_client.ts +++ b/src/platform/plugins/shared/home/public/application/sample_data_client.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { buildPath } from '@kbn/core-http-browser'; import { getServices } from './kibana_services'; const sampleDataUrl = '/api/sample_data'; @@ -30,7 +31,7 @@ export async function installSampleDataSet(id: string, sampleDataDefaultIndex: s } export async function uninstallSampleDataSet(id: string, sampleDataDefaultIndex: string) { - await getServices().http.delete(`${sampleDataUrl}/${id}`); + await getServices().http.delete(buildPath(`${sampleDataUrl}/{id}`, { id })); const uiSettings = getServices().uiSettings; diff --git a/src/platform/plugins/shared/home/tsconfig.json b/src/platform/plugins/shared/home/tsconfig.json index 9ab5a37cb3d97..6a0d93ee7e71d 100644 --- a/src/platform/plugins/shared/home/tsconfig.json +++ b/src/platform/plugins/shared/home/tsconfig.json @@ -35,6 +35,7 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/deeplinks-observability", "@kbn/react-kibana-context-theme", + "@kbn/core-http-browser", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/private/canvas/moon.yml b/x-pack/platform/plugins/private/canvas/moon.yml index 223c5573aed25..e8416ab6b397b 100644 --- a/x-pack/platform/plugins/private/canvas/moon.yml +++ b/x-pack/platform/plugins/private/canvas/moon.yml @@ -77,6 +77,7 @@ dependsOn: - '@kbn/shared-ux-markdown' - '@kbn/content-management-utils' - '@kbn/deeplinks-analytics' + - '@kbn/core-http-browser' tags: - plugin - prod diff --git a/x-pack/platform/plugins/private/canvas/public/services/canvas_custom_element_service.ts b/x-pack/platform/plugins/private/canvas/public/services/canvas_custom_element_service.ts index 1f0e13d2fbeae..47e8b2703300a 100644 --- a/x-pack/platform/plugins/private/canvas/public/services/canvas_custom_element_service.ts +++ b/x-pack/platform/plugins/private/canvas/public/services/canvas_custom_element_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { buildPath } from '@kbn/core-http-browser'; import { API_ROUTE_CUSTOM_ELEMENT } from '../../common/lib'; import { CustomElement } from '../../types'; import { coreServices } from './kibana_services'; @@ -31,14 +32,14 @@ class CanvasCustomElementService { } public async update(id: string, element: Partial) { - await coreServices.http.put(`${this.apiPath}/${id}`, { + await coreServices.http.put(buildPath(`${this.apiPath}/{id}`, { id }), { body: JSON.stringify(element), version: '1', }); } public async remove(id: string) { - await coreServices.http.delete(`${this.apiPath}/${id}`, { version: '1' }); + await coreServices.http.delete(buildPath(`${this.apiPath}/{id}`, { id }), { version: '1' }); } public async find(searchTerm: string): Promise { diff --git a/x-pack/platform/plugins/private/canvas/public/services/canvas_workpad_service.ts b/x-pack/platform/plugins/private/canvas/public/services/canvas_workpad_service.ts index 2672f6ef4a06e..6c73b1c05d6ed 100644 --- a/x-pack/platform/plugins/private/canvas/public/services/canvas_workpad_service.ts +++ b/x-pack/platform/plugins/private/canvas/public/services/canvas_workpad_service.ts @@ -6,6 +6,7 @@ */ import { ResolvedSimpleSavedObject, SavedObject } from '@kbn/core/public'; +import { buildPath } from '@kbn/core-http-browser'; import { API_ROUTE_SHAREABLE_ZIP, API_ROUTE_TEMPLATES, @@ -71,7 +72,9 @@ class CanvasWorkpadService { private apiPath = `${API_ROUTE_WORKPAD}`; public async get(id: string): Promise { - const workpad = await coreServices.http.get(`${this.apiPath}/${id}`, { version: '1' }); + const workpad = await coreServices.http.get(buildPath(`${this.apiPath}/{id}`, { id }), { + version: '1', + }); return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; } @@ -112,7 +115,7 @@ class CanvasWorkpadService { } public async create(workpad: CanvasWorkpad): Promise { - return coreServices.http.post(this.apiPath, { + return await coreServices.http.post(this.apiPath, { body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }), assets: workpad.assets || {}, @@ -123,7 +126,7 @@ class CanvasWorkpadService { } public async import(workpad: CanvasWorkpad): Promise { - return coreServices.http.post(`${this.apiPath}/import`, { + return await coreServices.http.post(`${this.apiPath}/import`, { body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }), assets: workpad.assets || {}, @@ -134,21 +137,21 @@ class CanvasWorkpadService { } public async createFromTemplate(templateId: string): Promise { - return coreServices.http.post(this.apiPath, { + return await coreServices.http.post(this.apiPath, { body: JSON.stringify({ templateId }), version: '1', }); } public async findTemplates(): Promise { - return coreServices.http.get(API_ROUTE_TEMPLATES, { version: '1' }); + return await coreServices.http.get(API_ROUTE_TEMPLATES, { version: '1' }); } public async find(searchTerm: string): Promise { // TODO: this shouldn't be necessary. Check for usage. const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - return coreServices.http.get(`${this.apiPath}/find`, { + return await coreServices.http.get(`${this.apiPath}/find`, { query: { perPage: 10000, name: validSearchTerm ? searchTerm : '', @@ -158,25 +161,27 @@ class CanvasWorkpadService { } public async remove(id: string) { - coreServices.http.delete(`${this.apiPath}/${id}`, { version: '1' }); + return await coreServices.http.delete(buildPath(`${this.apiPath}/{id}`, { id }), { + version: '1', + }); } public async update(id: string, workpad: CanvasWorkpad) { - coreServices.http.put(`${this.apiPath}/${id}`, { + return await coreServices.http.put(buildPath(`${this.apiPath}/{id}`, { id }), { body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), version: '1', }); } public async updateWorkpad(id: string, workpad: CanvasWorkpad) { - coreServices.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, { + return await coreServices.http.put(buildPath(`${API_ROUTE_WORKPAD_STRUCTURES}/{id}`, { id }), { body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), version: '1', }); } public async updateAssets(id: string, assets: CanvasWorkpad['assets']) { - coreServices.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, { + return await coreServices.http.put(buildPath(`${API_ROUTE_WORKPAD_ASSETS}/{id}`, { id }), { body: JSON.stringify(assets), version: '1', }); diff --git a/x-pack/platform/plugins/private/canvas/tsconfig.json b/x-pack/platform/plugins/private/canvas/tsconfig.json index 062d35808a515..610fa18574862 100644 --- a/x-pack/platform/plugins/private/canvas/tsconfig.json +++ b/x-pack/platform/plugins/private/canvas/tsconfig.json @@ -85,7 +85,8 @@ "@kbn/shared-ux-error-boundary", "@kbn/shared-ux-markdown", "@kbn/content-management-utils", - "@kbn/deeplinks-analytics" + "@kbn/deeplinks-analytics", + "@kbn/core-http-browser" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/private/logstash/moon.yml b/x-pack/platform/plugins/private/logstash/moon.yml index e186bb8621167..d75e668d37693 100644 --- a/x-pack/platform/plugins/private/logstash/moon.yml +++ b/x-pack/platform/plugins/private/logstash/moon.yml @@ -31,6 +31,7 @@ dependsOn: - '@kbn/react-kibana-context-render' - '@kbn/core-plugins-browser' - '@kbn/scout' + - '@kbn/core-http-browser' tags: - plugin - prod diff --git a/x-pack/platform/plugins/private/logstash/public/services/pipeline/pipeline_service.js b/x-pack/platform/plugins/private/logstash/public/services/pipeline/pipeline_service.js index 07547cf278d8f..cc0eeeeebf94b 100755 --- a/x-pack/platform/plugins/private/logstash/public/services/pipeline/pipeline_service.js +++ b/x-pack/platform/plugins/private/logstash/public/services/pipeline/pipeline_service.js @@ -5,6 +5,7 @@ * 2.0. */ +import { buildPath } from '@kbn/core-http-browser'; import { ROUTES } from '../../../common/constants'; import { Pipeline } from '../../models/pipeline'; @@ -32,7 +33,7 @@ export class PipelineService { deletePipeline(id) { return this.http - .delete(`${ROUTES.API_ROOT}/pipeline/${id}`) + .delete(buildPath(`${ROUTES.API_ROOT}/pipeline/{id}`, { id })) .then(() => this.pipelinesService.addToRecentlyDeleted(id)) .catch((e) => { throw e.message; diff --git a/x-pack/platform/plugins/private/logstash/tsconfig.json b/x-pack/platform/plugins/private/logstash/tsconfig.json index 2d47db1f93322..d98f45c957fd0 100644 --- a/x-pack/platform/plugins/private/logstash/tsconfig.json +++ b/x-pack/platform/plugins/private/logstash/tsconfig.json @@ -19,7 +19,8 @@ "@kbn/code-editor", "@kbn/react-kibana-context-render", "@kbn/core-plugins-browser", - "@kbn/scout" + "@kbn/scout", + "@kbn/core-http-browser" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/osquery/moon.yml b/x-pack/platform/plugins/shared/osquery/moon.yml index 87e23ba95f57f..9aaebaf51eb9a 100644 --- a/x-pack/platform/plugins/shared/osquery/moon.yml +++ b/x-pack/platform/plugins/shared/osquery/moon.yml @@ -71,6 +71,7 @@ dependsOn: - '@kbn/zod' - '@kbn/setup-node-env' - '@kbn/react-query' + - '@kbn/core-http-browser' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/osquery/public/packs/use_delete_pack.ts b/x-pack/platform/plugins/shared/osquery/public/packs/use_delete_pack.ts index a418d21dece7b..2d9a435afdd02 100644 --- a/x-pack/platform/plugins/shared/osquery/public/packs/use_delete_pack.ts +++ b/x-pack/platform/plugins/shared/osquery/public/packs/use_delete_pack.ts @@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from '@kbn/react-query'; import { i18n } from '@kbn/i18n'; +import { buildPath } from '@kbn/core-http-browser'; import { API_VERSIONS } from '../../common/constants'; import { useKibana } from '../common/lib/kibana'; import { PLUGIN_ID } from '../../common'; @@ -31,7 +32,7 @@ export const useDeletePack = ({ packId, withRedirect }: UseDeletePackProps) => { return useMutation( () => - http.delete(`/api/osquery/packs/${packId}`, { + http.delete(buildPath('/api/osquery/packs/{packId}', { packId }), { version: API_VERSIONS.public.v1, }), { diff --git a/x-pack/platform/plugins/shared/osquery/public/saved_queries/use_delete_saved_query.ts b/x-pack/platform/plugins/shared/osquery/public/saved_queries/use_delete_saved_query.ts index 1c5816ea46c14..29cce57954439 100644 --- a/x-pack/platform/plugins/shared/osquery/public/saved_queries/use_delete_saved_query.ts +++ b/x-pack/platform/plugins/shared/osquery/public/saved_queries/use_delete_saved_query.ts @@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from '@kbn/react-query'; import { i18n } from '@kbn/i18n'; +import { buildPath } from '@kbn/core-http-browser'; import { API_VERSIONS } from '../../common/constants'; import { useKibana } from '../common/lib/kibana'; import { PLUGIN_ID } from '../../common'; @@ -30,7 +31,7 @@ export const useDeleteSavedQuery = ({ savedQueryId }: UseDeleteSavedQueryProps) return useMutation( () => - http.delete(`/api/osquery/saved_queries/${savedQueryId}`, { + http.delete(buildPath('/api/osquery/saved_queries/{savedQueryId}', { savedQueryId }), { version: API_VERSIONS.public.v1, }), { diff --git a/x-pack/platform/plugins/shared/osquery/tsconfig.json b/x-pack/platform/plugins/shared/osquery/tsconfig.json index 4f1ead66e5593..0c5c916e84f7d 100644 --- a/x-pack/platform/plugins/shared/osquery/tsconfig.json +++ b/x-pack/platform/plugins/shared/osquery/tsconfig.json @@ -78,6 +78,7 @@ "@kbn/repo-info", "@kbn/zod", "@kbn/setup-node-env", - "@kbn/react-query" + "@kbn/react-query", + "@kbn/core-http-browser" ] } diff --git a/x-pack/solutions/observability/plugins/observability/public/components/annotations/hooks/use_delete_annotation.tsx b/x-pack/solutions/observability/plugins/observability/public/components/annotations/hooks/use_delete_annotation.tsx index 7feae3095fd52..7a50bb6aea2ac 100644 --- a/x-pack/solutions/observability/plugins/observability/public/components/annotations/hooks/use_delete_annotation.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/components/annotations/hooks/use_delete_annotation.tsx @@ -12,6 +12,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import { buildPath } from '@kbn/core-http-browser'; import type { Annotation } from '../../../../common/annotations'; import { useKibana } from '../../../utils/kibana_react'; @@ -30,7 +31,7 @@ export function useDeleteAnnotation() { ['deleteAnnotation'], async ({ annotations }) => { for (const annotation of annotations) { - await http.delete(`/api/observability/annotation/${annotation.id}`); + await http.delete(buildPath('/api/observability/annotation/{id}', { id: annotation.id })); } return Promise.resolve(); }, diff --git a/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts b/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts index 80af793a42f80..7adf675277d9e 100644 --- a/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts +++ b/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { buildPath } from '@kbn/core-http-browser'; import { i18n } from '@kbn/i18n'; import { DeleteConnectorResponse } from '../../../../../common/types/connectors'; @@ -26,11 +27,14 @@ export const deleteConnector = async ({ connectorName, shouldDeleteIndex = false, }: DeleteConnectorApiLogicArgs): Promise => { - await HttpLogic.values.http.delete(`/internal/enterprise_search/connectors/${connectorId}`, { - query: { - shouldDeleteIndex, - }, - }); + await HttpLogic.values.http.delete( + buildPath('/internal/enterprise_search/connectors/{connectorId}', { connectorId }), + { + query: { + shouldDeleteIndex, + }, + } + ); return { connectorName }; };