diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index e449a70b83dfa..22a3af2d474a2 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -26,7 +26,7 @@ "@types/jscodeshift": "^0.12.0", "@types/minimatch": "^3.0.5", "@types/minimist": "^1.2.5", - "@types/node": "22.19.0", + "@types/node": "24.10.13", "jest": "^30.0.3", "jscodeshift": "^17.1.2", "nock": "^12.0.2", @@ -1937,13 +1937,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", - "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/stack-utils": { @@ -5835,9 +5835,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -7480,12 +7480,12 @@ "dev": true }, "@types/node": { - "version": "22.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", - "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "requires": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "@types/stack-utils": { @@ -10074,9 +10074,9 @@ "dev": true }, "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true }, "universal-user-agent": { diff --git a/.buildkite/package.json b/.buildkite/package.json index dd511ed930b8b..917cd1c20d881 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -26,7 +26,7 @@ "@types/jscodeshift": "^0.12.0", "@types/minimatch": "^3.0.5", "@types/minimist": "^1.2.5", - "@types/node": "22.19.0", + "@types/node": "24.10.13", "jest": "^30.0.3", "jscodeshift": "^17.1.2", "nock": "^12.0.2", diff --git a/.node-version b/.node-version index 85e502778f623..8e35034890556 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.22.0 +24.14.1 diff --git a/.nvmrc b/.nvmrc index 85e502778f623..8e35034890556 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.22.0 +24.14.1 diff --git a/.yarnrc b/.yarnrc index b974847df98b5..c5bd506ad84ee 100644 --- a/.yarnrc +++ b/.yarnrc @@ -6,3 +6,7 @@ yarn-offline-mirror ".yarn-local-mirror" # Install scripts are managed by `yarn kbn bootstrap` via @kbn/yarn-install-scripts ignore-scripts true + +# Temporary for Node 24 migration: @elastic/ems-client@8.6.3 declares `engines.node: >=18 <=22` +# even though Kibana supports Node 24. +--install.ignore-engines true diff --git a/package.json b/package.json index 210625a3876e6..b03c98edb80e2 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "url": "https://github.com/elastic/kibana.git" }, "engines": { - "node": "22.22.0", + "node": "24.14.1", "yarn": "^1.22.19" }, "resolutions": { @@ -84,7 +84,7 @@ "**/@hello-pangea/dnd": "18.0.1", "**/@langchain/core": "1.1.31", "**/@langchain/google-common": "2.1.24", - "**/@types/node": "22.19.1", + "**/@types/node": "24.10.13", "**/@types/prop-types": "15.7.5", "**/@typescript-eslint/utils": "8.46.3", "**/baseline-browser-mapping": "2.9.14", @@ -1513,7 +1513,7 @@ "type-fest": "4.41.0", "typescript-fsa": "3.0.0", "typescript-fsa-reducers": "1.2.2", - "undici": "6.23.0", + "undici": "7.24.4", "unidiff": "1.0.4", "unified": "9.2.2", "use-resize-observer": "9.1.0", @@ -1930,7 +1930,7 @@ "@types/moment-duration-format": "2.2.7", "@types/mustache": "4.2.5", "@types/nock": "10.0.3", - "@types/node": "22.19.0", + "@types/node": "24.10.13", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.14", "@types/nodemailer": "7.0.6", diff --git a/packages/kbn-cli-dev-mode/src/base_path_proxy/http2.ts b/packages/kbn-cli-dev-mode/src/base_path_proxy/http2.ts index 3ac3a16966df0..00f59c43df7a0 100644 --- a/packages/kbn-cli-dev-mode/src/base_path_proxy/http2.ts +++ b/packages/kbn-cli-dev-mode/src/base_path_proxy/http2.ts @@ -117,7 +117,8 @@ export class Http2BasePathProxyServer implements BasePathProxyServer { server.listen(this.httpConfig.port, this.httpConfig.host, () => { server.on('request', (inboundRequest, inboundResponse) => { - const requestPath = Url.parse(inboundRequest.url).path ?? '/'; + const parsedRequestUrl = new URL(inboundRequest.url ?? '/', 'http://localhost'); + const requestPath = `${parsedRequestUrl.pathname}${parsedRequestUrl.search}` || '/'; if (requestPath === '/') { // Always redirect from root URL to the URL with basepath. diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts index 9f8b3f93f278a..026d17304cd1a 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/github_api.ts @@ -7,14 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Url from 'url'; - import type { AxiosRequestConfig, AxiosInstance, AxiosHeaderValue } from 'axios'; import Axios, { AxiosHeaders } from 'axios'; import { isAxiosResponseError, isAxiosRequestError } from '@kbn/dev-utils'; import type { ToolingLog } from '@kbn/tooling-log'; const BASE_URL = 'https://api.github.com/repos/elastic/kibana/'; +const resolveGithubUrl = (path: string) => new URL(path, BASE_URL).toString(); export interface GithubIssue { html_url: string; @@ -81,7 +80,7 @@ export class GithubApi { await this.request( { method: 'PATCH', - url: Url.resolve(BASE_URL, `issues/${encodeURIComponent(issueNumber)}`), + url: resolveGithubUrl(`issues/${encodeURIComponent(issueNumber)}`), data: { state: 'open', // Reopen issue if it was closed. body: newBody, @@ -95,7 +94,7 @@ export class GithubApi { await this.request( { method: 'POST', - url: Url.resolve(BASE_URL, `issues/${encodeURIComponent(issueNumber)}/comments`), + url: resolveGithubUrl(`issues/${encodeURIComponent(issueNumber)}/comments`), data: { body: commentBody, }, @@ -108,7 +107,7 @@ export class GithubApi { const resp = await this.request( { method: 'POST', - url: Url.resolve(BASE_URL, 'issues'), + url: resolveGithubUrl('issues'), data: { title, body, diff --git a/packages/kbn-plugin-helpers/src/tasks/optimize_worker.ts b/packages/kbn-plugin-helpers/src/tasks/optimize_worker.ts index de4c852fd16d6..67c2b05f85ffd 100644 --- a/packages/kbn-plugin-helpers/src/tasks/optimize_worker.ts +++ b/packages/kbn-plugin-helpers/src/tasks/optimize_worker.ts @@ -12,7 +12,7 @@ import type { WorkerConfig } from '@kbn/optimizer/src/common'; import { parseBundles, BundleRemotes } from '@kbn/optimizer/src/common'; import { getWebpackConfig } from '@kbn/optimizer/src/worker/webpack.config'; -const send = process.send; +const send = process.send?.bind(process); if (!send) { throw new Error('must be run as a node.js fork'); } @@ -36,25 +36,34 @@ process.on('message', (msg: any) => { }, (error, stats) => { if (error) { - send.call(process, { - success: false, - error: error.message, - }); + send( + { + success: false, + error: error.message, + }, + undefined + ); return; } if (stats?.hasErrors()) { - send.call(process, { - success: false, - error: `Failed to compile with webpack:\n${stats.toString()}`, - }); + send( + { + success: false, + error: `Failed to compile with webpack:\n${stats.toString()}`, + }, + undefined + ); return; } - send.call(process, { - success: true, - warnings: stats?.hasWarnings() ? stats.toString() : '', - }); + send( + { + success: true, + warnings: stats?.hasWarnings() ? stats.toString() : '', + }, + undefined + ); } ); }); diff --git a/packages/kbn-relocate/healthcheck.ts b/packages/kbn-relocate/healthcheck.ts index 5ebaf501cc701..39e03df792fe5 100644 --- a/packages/kbn-relocate/healthcheck.ts +++ b/packages/kbn-relocate/healthcheck.ts @@ -66,9 +66,9 @@ const checkIfResourceExists = (baseDir: string, reference: string): boolean => { const getAllFiles = ( dirPath: string, - arrayOfFiles: fs.Dirent[] = [], + arrayOfFiles: string[] = [], extensions?: string[] -): fs.Dirent[] => { +): string[] => { const files = fs.readdirSync(dirPath, { withFileTypes: true }); files.forEach((file) => { @@ -77,12 +77,10 @@ const getAllFiles = ( !EXCLUDED_FOLDERS.some((folder) => filePath.startsWith(join(BASE_FOLDER, folder))) && !EXCLUDED_FOLDER_NAMES.includes(file.name) ) { - if (fs.statSync(filePath).isDirectory()) { - arrayOfFiles = getAllFiles(filePath, arrayOfFiles); - } else { - if (!extensions || extensions.find((ext) => file.name.endsWith(ext))) { - arrayOfFiles.push(file); - } + if (file.isDirectory()) { + arrayOfFiles = getAllFiles(filePath, arrayOfFiles, extensions); + } else if (!extensions || extensions.find((ext) => file.name.endsWith(ext))) { + arrayOfFiles.push(filePath); } } }); @@ -95,14 +93,14 @@ export const findBrokenReferences = async (log: ToolingLog) => { const moduleNames = packages.map((pkg) => pkg.directory.split('/').pop()!); const files = getAllFiles(BASE_FOLDER, [], EXTENSIONS); - for (const file of files) { - const fileBrokenReferences = []; - const filePath = join(file.path, file.name); + for (const filePath of files) { + const fileBrokenReferences: string[] = []; + const baseDir = path.dirname(filePath); const content = fs.readFileSync(filePath, 'utf-8'); const references = findPaths(content); for (const ref of references) { - if (isModuleReference(moduleNames, ref) && !checkIfResourceExists(file.path, ref)) { + if (isModuleReference(moduleNames, ref) && !checkIfResourceExists(baseDir, ref)) { fileBrokenReferences.push(ref); } } @@ -116,9 +114,8 @@ export const findBrokenReferences = async (log: ToolingLog) => { export const findBrokenLinks = async (log: ToolingLog) => { const files = getAllFiles(BASE_FOLDER); - for (const file of files) { - const fileBrokenLinks = []; - const filePath = join(file.path, file.name); + for (const filePath of files) { + const fileBrokenLinks: string[] = []; const content = fs.readFileSync(filePath, 'utf-8'); const references = findUrls(content); diff --git a/packages/kbn-styled-components-mapping-cli/src/find_files.ts b/packages/kbn-styled-components-mapping-cli/src/find_files.ts index 8bbedf27ba515..e24506c7f0a8b 100644 --- a/packages/kbn-styled-components-mapping-cli/src/find_files.ts +++ b/packages/kbn-styled-components-mapping-cli/src/find_files.ts @@ -30,7 +30,7 @@ const walkDirectory = async (dirPath: string): Promise => { let usesOnlyStyledComponents = true; for (const file of await fs.readdir(dirPath, { withFileTypes: true })) { - const fullPath = path.join(file.path, file.name); + const fullPath = path.join(dirPath, file.name); if (file.isDirectory()) { const meta = await walkDirectory(fullPath); diff --git a/src/cli/plugin/install/download.js b/src/cli/plugin/install/download.js index 22164e6106146..4cdd10768d94e 100644 --- a/src/cli/plugin/install/download.js +++ b/src/cli/plugin/install/download.js @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse } from 'url'; - import { UnsupportedProtocolError } from '../lib/errors'; import { downloadHttpFile } from './downloaders/http'; import { downloadLocalFile } from './downloaders/file'; @@ -35,14 +33,14 @@ export function _checkFilePathDeprecation(sourceUrl, logger) { } export function _downloadSingle(settings, logger, sourceUrl) { - const urlInfo = parse(sourceUrl); + const urlInfo = new URL(sourceUrl); let downloadPromise; if (/^file/.test(urlInfo.protocol)) { _checkFilePathDeprecation(sourceUrl, logger); downloadPromise = downloadLocalFile( logger, - _getFilePath(urlInfo.path), + _getFilePath(urlInfo.pathname), settings.tempArchiveFile ); } else if (/^https?/.test(urlInfo.protocol)) { @@ -71,7 +69,14 @@ export function download(settings, logger) { logger.log(`Attempting to transfer from ${sourceUrl}`); - return _downloadSingle(settings, logger, sourceUrl).catch((err) => { + let singleResult; + try { + singleResult = _downloadSingle(settings, logger, sourceUrl); + } catch (err) { + return tryNext(); + } + + return singleResult.catch((err) => { const isUnsupportedProtocol = err instanceof UnsupportedProtocolError; const isDownloadResourceNotFound = err.message === 'ENOTFOUND'; if (isUnsupportedProtocol || isDownloadResourceNotFound) { diff --git a/src/cli/plugin/install/download.test.js b/src/cli/plugin/install/download.test.js index 0af68fe23ded5..c50be96098cba 100644 --- a/src/cli/plugin/install/download.test.js +++ b/src/cli/plugin/install/download.test.js @@ -17,7 +17,6 @@ import globby from 'globby'; import del from 'del'; import { Logger } from '../../logger'; -import { UnsupportedProtocolError } from '../lib/errors'; import { download, _downloadSingle, _getFilePath, _checkFilePathDeprecation } from './download'; describe('kibana cli', function () { @@ -77,13 +76,11 @@ describe('kibana cli', function () { }); }); - it('should throw an UnsupportedProtocolError for an invalid url', function () { + it('should throw a TypeError for an invalid url', function () { const sourceUrl = 'i am an invalid url'; - return _downloadSingle(settings, logger, sourceUrl).then(shouldReject, function (err) { - expect(err).toBeInstanceOf(UnsupportedProtocolError); - expectWorkingPathEmpty(); - }); + expect(() => _downloadSingle(settings, logger, sourceUrl)).toThrow(/Invalid URL/); + expectWorkingPathEmpty(); }); it('should download a file from a valid http url', function () { diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 5c8be1990190c..3c1bb6b767004 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -10,7 +10,7 @@ import { set as lodashSet } from '@kbn/safer-lodash-set'; import _ from 'lodash'; import { resolve } from 'path'; -import url from 'url'; +import { URL } from 'url'; import { isKibanaDistributable } from '@kbn/repo-info'; import { readKeystore } from '../keystore/lib/read_keystore'; @@ -37,6 +37,14 @@ function canRequire(path) { } } +function tryParseUrl(value) { + try { + return new URL(value); + } catch { + return; + } +} + const getBootstrapScript = (isDev) => { if (DEV_MODE_SUPPORTED && isDev && process.env.isDevCliChild !== 'true') { // need dynamic require to exclude it from production build @@ -63,11 +71,11 @@ const setServerlessKibanaDevServiceAccountIfPossible = (get, set, opts) => { */ const isESlocalhost = esHosts.length ? esHosts.some((hostUrl) => { - const parsedUrl = url.parse(hostUrl); + const parsedUrl = tryParseUrl(hostUrl); return ( - parsedUrl.hostname === 'localhost' || - parsedUrl.hostname === '127.0.0.1' || - parsedUrl.hostname === 'host.docker.internal' + parsedUrl?.hostname === 'localhost' || + parsedUrl?.hostname === '127.0.0.1' || + parsedUrl?.hostname === 'host.docker.internal' ); }) : true; // default is localhost:9200 @@ -178,13 +186,13 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions, keystoreC 'https://localhost:9200', ] ).map((hostUrl) => { - const parsedUrl = url.parse(hostUrl); - if (parsedUrl.hostname !== 'localhost') { + const parsedUrl = tryParseUrl(hostUrl); + if (parsedUrl?.hostname !== 'localhost') { throw new Error( - `Hostname "${parsedUrl.hostname}" can't be used with --ssl. Must be "localhost" to work with certificates.` + `Hostname "${parsedUrl?.hostname}" can't be used with --ssl. Must be "localhost" to work with certificates.` ); } - return `https://localhost:${parsedUrl.port}`; + return `https://localhost${parsedUrl.port ? `:${parsedUrl.port}` : ''}`; }); set('elasticsearch.hosts', elasticsearchHosts); diff --git a/src/cli/serve/serve.test.js b/src/cli/serve/serve.test.js index ab430c84d9881..0f3480225afa9 100644 --- a/src/cli/serve/serve.test.js +++ b/src/cli/serve/serve.test.js @@ -159,4 +159,42 @@ describe('applyConfigOverrides', () => { }, }); }); + + it('injects the serverless service account token for localhost Elasticsearch hosts', () => { + expect( + applyConfigOverrides( + { + elasticsearch: { + hosts: ['https://localhost:9200'], + }, + }, + { dev: true, serverless: true }, + {}, + {} + ) + ).toEqual( + expect.objectContaining({ + elasticsearch: { + hosts: ['https://localhost:9200'], + serviceAccountToken: kibanaDevServiceAccount.token, + ssl: { certificateAuthorities: expect.stringContaining('ca.crt') }, + }, + }) + ); + }); + + it('preserves the configured localhost Elasticsearch port when enabling ssl', () => { + expect( + applyConfigOverrides({}, { ssl: true, elasticsearch: 'https://localhost:9400' }, {}, {}) + ).toEqual( + expect.objectContaining({ + elasticsearch: { + hosts: ['https://localhost:9400'], + ssl: { + certificateAuthorities: expect.stringContaining('ca.crt'), + }, + }, + }) + ); + }); }); diff --git a/src/core/packages/application/browser-internal/src/utils/parse_app_url.ts b/src/core/packages/application/browser-internal/src/utils/parse_app_url.ts index 4bed60f168e4d..5c09f513a9d06 100644 --- a/src/core/packages/application/browser-internal/src/utils/parse_app_url.ts +++ b/src/core/packages/application/browser-internal/src/utils/parse_app_url.ts @@ -8,7 +8,6 @@ */ import { getUrlOrigin } from '@kbn/std'; -import { resolve } from 'url'; import type { IBasePath } from '@kbn/core-http-browser'; import type { App } from '@kbn/core-application-browser'; import type { ParsedAppUrl } from '../types'; @@ -46,7 +45,18 @@ export const parseAppUrl = ( // if the path is relative (i.e `../../to/somewhere`), we convert it to absolute if (!url.startsWith('/')) { - url = resolve(currentPath, url); + // Do not parse absolute URL as app URL + try { + const parsed = new URL(url); + if (parsed.origin) { + return undefined; + } + } catch { + // not a valid absolute URL, treat as relative + } + + const resolvedUrl = new URL(url, `${currentOrigin}${currentPath}`); + url = `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`; } // if using a basePath and the absolute path does not starts with it, it can't be a match diff --git a/src/core/packages/chrome/browser-internal/src/state/visibility_state.ts b/src/core/packages/chrome/browser-internal/src/state/visibility_state.ts index 5ea020dd9faeb..a68cf2f3500a0 100644 --- a/src/core/packages/chrome/browser-internal/src/state/visibility_state.ts +++ b/src/core/packages/chrome/browser-internal/src/state/visibility_state.ts @@ -19,7 +19,6 @@ import { shareReplay, startWith, } from 'rxjs'; -import { parse } from 'url'; import type { InternalApplicationStart } from '@kbn/core-application-browser-internal'; import { createState } from './state_helpers'; @@ -35,7 +34,8 @@ export interface VisibilityState { export const createVisibilityState = ({ application }: VisibilityStateDeps): VisibilityState => { // Start off the chrome service hidden if "embed" is in the hash query string. - const isEmbedded = 'embed' in parse(location.hash.slice(1), true).query; + const hashUrl = new URL(location.hash.slice(1) || '/', 'http://localhost'); + const isEmbedded = hashUrl.searchParams.has('embed'); const forceHidden = createState(isEmbedded); /** Emits true during printing (window.beforeprint), false otherwise. */ diff --git a/src/core/packages/elasticsearch/client-server-internal/src/get_agents_sockets_stats.test.ts b/src/core/packages/elasticsearch/client-server-internal/src/get_agents_sockets_stats.test.ts index 8b6fc71ffda1e..4bb124ebdbe4e 100644 --- a/src/core/packages/elasticsearch/client-server-internal/src/get_agents_sockets_stats.test.ts +++ b/src/core/packages/elasticsearch/client-server-internal/src/get_agents_sockets_stats.test.ts @@ -8,15 +8,14 @@ */ import { Socket } from 'net'; -import type { Agent } from 'http'; -import { IncomingMessage } from 'http'; +import type { Agent, ClientRequest } from 'http'; import { getAgentsSocketsStats } from './get_agents_sockets_stats'; import { getHttpAgentMock, getHttpsAgentMock } from './get_agents_sockets_stats.test.mocks'; jest.mock('net'); const mockSocket = new Socket(); -const mockIncomingMessage = new IncomingMessage(mockSocket); +const mockClientRequest = {} as unknown as ClientRequest; describe('getAgentsSocketsStats()', () => { it('extracts aggregated stats from the specified agents', () => { @@ -30,7 +29,7 @@ describe('getAgentsSocketsStats()', () => { node3: [mockSocket, mockSocket, mockSocket, mockSocket], }, requests: { - node1: [mockIncomingMessage, mockIncomingMessage], + node1: [mockClientRequest, mockClientRequest], }, }); @@ -43,7 +42,7 @@ describe('getAgentsSocketsStats()', () => { node3: [mockSocket, mockSocket, mockSocket, mockSocket], }, requests: { - node4: [mockIncomingMessage, mockIncomingMessage, mockIncomingMessage, mockIncomingMessage], + node4: [mockClientRequest, mockClientRequest, mockClientRequest, mockClientRequest], }, }); diff --git a/src/core/packages/http/router-server-internal/src/response_adapter.ts b/src/core/packages/http/router-server-internal/src/response_adapter.ts index 5b242161e1490..f46f2cc4f361e 100644 --- a/src/core/packages/http/router-server-internal/src/response_adapter.ts +++ b/src/core/packages/http/router-server-internal/src/response_adapter.ts @@ -173,7 +173,7 @@ function getErrorMessage(payload?: ResponseError): string { } function isStreamOrBuffer(payload: ResponseError): payload is stream.Stream | Buffer { - return Buffer.isBuffer(payload) || stream.isReadable(payload as stream.Readable); + return Buffer.isBuffer(payload) || stream.isReadable(payload as stream.Readable) === true; } function getErrorAttributes(payload?: ResponseError): ResponseErrorAttributes | undefined { diff --git a/src/core/packages/http/server-internal/src/http_config.ts b/src/core/packages/http/server-internal/src/http_config.ts index 9cf0864a92f52..306879b717be3 100644 --- a/src/core/packages/http/server-internal/src/http_config.ts +++ b/src/core/packages/http/server-internal/src/http_config.ts @@ -8,7 +8,7 @@ */ import { EOL, hostname } from 'node:os'; -import url, { URL } from 'node:url'; +import { URL } from 'node:url'; import type { Duration } from 'moment'; import type { ByteSizeValue, TypeOf } from '@kbn/config-schema'; import { offeringBasedSchema, schema } from '@kbn/config-schema'; @@ -286,12 +286,12 @@ const configSchema = schema.object( } if (rawConfig.publicBaseUrl) { - const parsedUrl = url.parse(rawConfig.publicBaseUrl); - if (parsedUrl.query || parsedUrl.hash || parsedUrl.auth) { + const parsedUrl = new URL(rawConfig.publicBaseUrl); + if (parsedUrl.search || parsedUrl.hash || parsedUrl.username || parsedUrl.password) { return `[publicBaseUrl] may only contain a protocol, host, port, and pathname`; } - if (parsedUrl.path !== (rawConfig.basePath ?? '/')) { - return `[publicBaseUrl] must contain the [basePath]: ${parsedUrl.path} !== ${rawConfig.basePath}`; + if (parsedUrl.pathname !== (rawConfig.basePath ?? '/')) { + return `[publicBaseUrl] must contain the [basePath]: ${parsedUrl.pathname} !== ${rawConfig.basePath}`; } } diff --git a/src/core/packages/http/server-internal/src/http_server.ts b/src/core/packages/http/server-internal/src/http_server.ts index c83f37aa4fabf..b67e7f994c15d 100644 --- a/src/core/packages/http/server-internal/src/http_server.ts +++ b/src/core/packages/http/server-internal/src/http_server.ts @@ -10,7 +10,6 @@ import { context, trace, type Span as OTelSpan } from '@opentelemetry/api'; import type { Request, Server } from '@hapi/hapi'; import HapiStaticFiles from '@hapi/inert'; -import url from 'url'; import { v4 as uuidv4 } from 'uuid'; import { createServer, getRequestId, getServerOptions, setTlsConfig } from '@kbn/server-http-tools'; import type { Duration } from 'moment'; @@ -66,6 +65,14 @@ import { BasePath } from './base_path_service'; import { getEcsResponseLog } from './logging'; import { type InternalStaticAssets, StaticAssets } from './static_assets'; +const getReferrerHostname = (referrer: string): string | undefined => { + try { + return new URL(referrer).hostname; + } catch { + return; + } +}; + /** * Adds ELU timings for the executed function to the current's context transaction * @@ -515,7 +522,7 @@ export class HttpServer { this.server.ext('onRequest', (request, h) => { const { referrer } = request.info; if (referrer !== '') { - const { hostname } = url.parse(referrer); + const hostname = getReferrerHostname(referrer); if (!hostname || !list.includes(hostname)) { request.info.acceptEncoding = ''; } diff --git a/src/dev/build/lib/get_build_number.ts b/src/dev/build/lib/get_build_number.ts index c5622ab6a1793..0fbd71d5ce18f 100644 --- a/src/dev/build/lib/get_build_number.ts +++ b/src/dev/build/lib/get_build_number.ts @@ -7,18 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import os from 'os'; import execa from 'execa'; export async function getBuildNumber() { - if (/^win/.test(os.platform())) { - // Windows does not have the wc process and `find /C /V ""` does not consistently work - const log = await execa('git', ['log', '--format="%h"']); - return log.stdout.split('\n').length; - } - - const wc = await execa.command('git log --format="%h" | wc -l', { - shell: true, - }); - return parseFloat(wc.stdout.trim()); + const count = await execa('git', ['rev-list', '--count', 'HEAD']); + return parseFloat(count.stdout.trim()); } diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts index 7d491aceca305..1f45061b0b0b2 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts @@ -10,7 +10,6 @@ import type { Client } from '@elastic/elasticsearch'; import { createHmac, randomBytes, X509Certificate } from 'crypto'; import { readFile } from 'fs/promises'; -import Url from 'url'; import { promisify } from 'util'; import { SignedXml } from 'xml-crypto'; import { parseString } from 'xml2js'; @@ -424,7 +423,7 @@ const inflateRawAsync = promisify(zlib.inflateRaw); const parseStringAsync = promisify(parseString); export async function getSAMLRequestId(requestUrl: string): Promise { - const samlRequest = Url.parse(requestUrl, true /* parseQueryString */).query.SAMLRequest; + const samlRequest = new URL(requestUrl).searchParams.get('SAMLRequest'); let requestId: string | undefined; diff --git a/src/platform/packages/private/kbn-reporting/export_types/pdf/get_full_urls.ts b/src/platform/packages/private/kbn-reporting/export_types/pdf/get_full_urls.ts index 2ab0bf53abebc..f43b4929d2ce8 100644 --- a/src/platform/packages/private/kbn-reporting/export_types/pdf/get_full_urls.ts +++ b/src/platform/packages/private/kbn-reporting/export_types/pdf/get_full_urls.ts @@ -7,9 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { UrlWithParsedQuery, UrlWithStringQuery } from 'url'; -import { format as urlFormat, parse as urlParse } from 'url'; - import type { ReportingServerInfo } from '@kbn/reporting-common/types'; import type { TaskPayloadPDF } from '@kbn/reporting-export-types-pdf-common'; import type { ReportingConfigType } from '@kbn/reporting-server'; @@ -44,16 +41,16 @@ export function getFullUrls( validateUrls(relativeUrls); const urls = relativeUrls.map((relativeUrl) => { - const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); + const parsedRelative = new URL(relativeUrl, 'http://localhost'); const jobUrl = getAbsoluteUrl({ - path: parsedRelative.pathname === null ? undefined : parsedRelative.pathname, - hash: parsedRelative.hash === null ? undefined : parsedRelative.hash, - search: parsedRelative.search === null ? undefined : parsedRelative.search, + path: parsedRelative.pathname || undefined, + hash: parsedRelative.hash || undefined, + search: parsedRelative.search || undefined, }); // capture the route to the visualization - const parsed: UrlWithParsedQuery = urlParse(jobUrl, true); - if (parsed.hash == null) { + const parsed = new URL(jobUrl); + if (!parsed.hash) { throw new Error( 'No valid hash in the URL! A hash is expected for the application to route to the intended visualization.' ); @@ -64,21 +61,11 @@ export function getFullUrls( return jobUrl; } - const visualizationRoute: UrlWithParsedQuery = urlParse(parsed.hash.replace(/^#/, ''), true); - - // combine the visualization route and forceNow parameter into a URL - const transformedHash = urlFormat({ - pathname: visualizationRoute.pathname, - query: { - ...visualizationRoute.query, - forceNow: job.forceNow, - }, - }); + const visualizationRoute = new URL(parsed.hash.replace(/^#/, ''), 'http://localhost'); + visualizationRoute.searchParams.set('forceNow', job.forceNow); + parsed.hash = `${visualizationRoute.pathname}${visualizationRoute.search}`; - return urlFormat({ - ...parsed, - hash: transformedHash, - }); + return parsed.toString(); }); return urls; diff --git a/src/platform/packages/private/kbn-reporting/export_types/pdf/validate_urls.ts b/src/platform/packages/private/kbn-reporting/export_types/pdf/validate_urls.ts index bcbec76b949d4..9b70deb084a74 100644 --- a/src/platform/packages/private/kbn-reporting/export_types/pdf/validate_urls.ts +++ b/src/platform/packages/private/kbn-reporting/export_types/pdf/validate_urls.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse } from 'url'; import { filter } from 'lodash'; /* @@ -18,9 +17,16 @@ import { filter } from 'lodash'; * to it, which url.parse doesn't catch all variants of */ const isBogusUrl = (url: string) => { - const { host, protocol, port } = parse(url, false, true); + if (url.trim().startsWith('//')) { + return true; + } - return host !== null || protocol !== null || port !== null; + try { + new URL(url.replace(/\s/g, '')); + return true; + } catch { + return false; + } }; export const validateUrls = (urls: string[]): void => { diff --git a/src/platform/packages/private/kbn-screenshotting-server/src/args.test.ts b/src/platform/packages/private/kbn-screenshotting-server/src/args.test.ts index 340c0a4158a7e..a02f771361f64 100644 --- a/src/platform/packages/private/kbn-screenshotting-server/src/args.test.ts +++ b/src/platform/packages/private/kbn-screenshotting-server/src/args.test.ts @@ -16,12 +16,13 @@ import { args } from './args'; describe('headless webgl arm mac workaround', () => { const originalPlatform = process.platform; afterEach(() => { + jest.restoreAllMocks(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); }); - const simulateEnv = (platform: string, arch: string) => { + const simulateEnv = (platform: string, arch: ReturnType) => { Object.defineProperty(process, 'platform', { value: platform }); jest.spyOn(os, 'arch').mockReturnValue(arch); }; diff --git a/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt b/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt index ec6d8005f9741..d2c624b2f98d3 100644 --- a/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt +++ b/src/platform/packages/private/kbn-ui-shared-deps-npm/version_dependencies.txt @@ -61,7 +61,7 @@ @types/lodash@4.17.0 @types/mdast@3.0.3 @types/minimatch@3.0.3 -@types/node@22.19.1 +@types/node@24.10.13 @types/numeral@2.0.5 @types/parse-json@4.0.0 @types/parse5@5.0.3 @@ -485,7 +485,7 @@ tslib@1.14.1 tslib@2.8.1 tty-browserify@0.0.1 typed-array-buffer@1.0.3 -undici-types@6.21.0 +undici-types@7.16.0 unherit@1.1.0 unified@9.2.2 unist-builder@2.0.3 diff --git a/src/platform/packages/private/kbn-ui-shared-deps-src/version_dependencies.txt b/src/platform/packages/private/kbn-ui-shared-deps-src/version_dependencies.txt index d38694d7cab7d..976f07fbf60a6 100644 --- a/src/platform/packages/private/kbn-ui-shared-deps-src/version_dependencies.txt +++ b/src/platform/packages/private/kbn-ui-shared-deps-src/version_dependencies.txt @@ -14,7 +14,7 @@ @types/estree@1.0.8 @types/js-cookie@2.2.6 @types/json-schema@7.0.11 -@types/node@22.19.1 +@types/node@24.10.13 @types/trusted-types@2.0.7 @webassemblyjs/ast@1.12.1 @webassemblyjs/floating-point-hex-parser@1.11.6 @@ -145,7 +145,7 @@ toggle-selection@1.0.6 ts-easing@0.2.0 tslib@1.14.1 tslib@2.8.1 -undici-types@6.21.0 +undici-types@7.16.0 update-browserslist-db@1.1.4 uri-js@4.2.2 util-deprecate@1.0.2 diff --git a/src/platform/packages/shared/kbn-es-archiver/src/cli.ts b/src/platform/packages/shared/kbn-es-archiver/src/cli.ts index 1dc53ee705450..fcff4a0a50833 100644 --- a/src/platform/packages/shared/kbn-es-archiver/src/cli.ts +++ b/src/platform/packages/shared/kbn-es-archiver/src/cli.ts @@ -14,7 +14,7 @@ *************************************************************/ import Path from 'path'; -import Url from 'url'; +import { format as formatUrl } from 'url'; import readline from 'readline'; import Fs from 'fs'; @@ -55,7 +55,7 @@ export function runCli() { throw createFlagError('--es-url must be a string'); } if (!esUrl && config) { - esUrl = Url.format(config.get('servers.elasticsearch')); + esUrl = formatUrl(config.get('servers.elasticsearch')); } if (!esUrl) { throw createFlagError('--es-url or --config must be defined'); @@ -66,7 +66,7 @@ export function runCli() { throw createFlagError('--kibana-url must be a string'); } if (!kibanaUrl && config) { - kibanaUrl = Url.format(config.get('servers.kibana')); + kibanaUrl = formatUrl(config.get('servers.kibana')); } if (!kibanaUrl) { throw createFlagError('--kibana-url or --config must be defined'); @@ -83,7 +83,7 @@ export function runCli() { } else if (kibanaCaPath) { kibanaCa = Fs.readFileSync(kibanaCaPath); } else { - const { protocol, hostname } = Url.parse(kibanaUrl); + const { protocol, hostname } = new URL(kibanaUrl); if (protocol === 'https:' && hostname === 'localhost') { kibanaCa = Fs.readFileSync(CA_CERT_PATH); } @@ -100,7 +100,7 @@ export function runCli() { } else if (esCaPath) { esCa = Fs.readFileSync(esCaPath); } else { - const { protocol, hostname } = Url.parse(kibanaUrl); + const { protocol, hostname } = new URL(kibanaUrl); if (protocol === 'https:' && hostname === 'localhost') { esCa = Fs.readFileSync(CA_CERT_PATH); } diff --git a/src/platform/packages/shared/kbn-ftr-common-functional-ui-services/services/browser.ts b/src/platform/packages/shared/kbn-ftr-common-functional-ui-services/services/browser.ts index 209a3ea438e89..726d4f24f2bb0 100644 --- a/src/platform/packages/shared/kbn-ftr-common-functional-ui-services/services/browser.ts +++ b/src/platform/packages/shared/kbn-ftr-common-functional-ui-services/services/browser.ts @@ -12,7 +12,6 @@ import { cloneDeepWith, isString } from 'lodash'; import { Key, Origin, type WebDriver } from 'selenium-webdriver'; import { Driver as ChromiumWebDriver } from 'selenium-webdriver/chrome'; import { setTimeout as setTimeoutAsync } from 'timers/promises'; -import Url from 'url'; import type { Protocol } from 'devtools-protocol'; import { NoSuchSessionError } from 'selenium-webdriver/lib/error'; @@ -192,8 +191,8 @@ class BrowserService extends FtrService { }); if (relativeUrl) { - const { path } = Url.parse(currentWithoutTime); - return path!; // this property includes query params and anchors + const parsedUrl = new URL(currentWithoutTime); + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; } else { return currentWithoutTime; } diff --git a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts index fbce63a942892..8311d41fd5ac7 100644 --- a/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts +++ b/src/platform/packages/shared/kbn-kbn-client/src/kbn_client/kbn_client_requester.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Url from 'url'; import Https from 'https'; import Qs from 'querystring'; @@ -96,7 +95,7 @@ export class KbnClientRequester { constructor(private readonly log: ToolingLog, options: Options) { this.url = options.url; this.httpsAgent = - Url.parse(options.url).protocol === 'https:' + new URL(options.url).protocol === 'https:' ? new Https.Agent({ ca: options.certificateAuthorities, rejectUnauthorized: false, @@ -113,8 +112,7 @@ export class KbnClientRequester { if (!baseUrl.endsWith('/')) { baseUrl += '/'; } - const relative = relativeUrl.startsWith('/') ? relativeUrl.slice(1) : relativeUrl; - return Url.resolve(baseUrl, relative); + return new URL(relativeUrl, baseUrl).toString(); } async request(options: ReqOptions): Promise> { diff --git a/src/platform/packages/shared/kbn-monaco/version_dependencies.txt b/src/platform/packages/shared/kbn-monaco/version_dependencies.txt index 8fef76b6f8f40..4b2e56b928cd6 100644 --- a/src/platform/packages/shared/kbn-monaco/version_dependencies.txt +++ b/src/platform/packages/shared/kbn-monaco/version_dependencies.txt @@ -9,7 +9,7 @@ @types/estree@0.0.50 @types/estree@1.0.8 @types/json-schema@7.0.11 -@types/node@22.19.1 +@types/node@24.10.13 @webassemblyjs/ast@1.12.1 @webassemblyjs/floating-point-hex-parser@1.11.6 @webassemblyjs/helper-api-error@1.11.6 @@ -94,7 +94,7 @@ tapable@2.3.0 terser-webpack-plugin@5.3.17 terser@5.40.0 tslib@1.14.1 -undici-types@6.21.0 +undici-types@7.16.0 update-browserslist-db@1.1.4 uri-js@4.2.2 vscode-jsonrpc@8.2.0 diff --git a/src/platform/packages/shared/kbn-std/src/parse_next_url.test.ts b/src/platform/packages/shared/kbn-std/src/parse_next_url.test.ts index 0bdb2c2b70796..246e51843b781 100644 --- a/src/platform/packages/shared/kbn-std/src/parse_next_url.test.ts +++ b/src/platform/packages/shared/kbn-std/src/parse_next_url.test.ts @@ -22,6 +22,12 @@ describe('parseNextURL', () => { expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`); }); + it('should return basePath with a trailing slash when next is empty', () => { + const basePath = '/iqf'; + const href = `${basePath}/login?next=`; + expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`); + }); + it('should properly handle next without hash', () => { const basePath = '/iqf'; const next = `${basePath}/app/kibana`; @@ -117,6 +123,11 @@ describe('parseNextURL', () => { expect(parseNextURL(href)).toEqual('/'); }); + it('should return / with a trailing slash when next is empty', () => { + const href = '/login?next='; + expect(parseNextURL(href)).toEqual('/'); + }); + it('should properly handle next without hash', () => { const next = '/app/kibana'; const href = `/login?next=${next}`; diff --git a/src/platform/packages/shared/kbn-std/src/parse_next_url.ts b/src/platform/packages/shared/kbn-std/src/parse_next_url.ts index 5addfebbf6b6b..eb141668a8ddd 100644 --- a/src/platform/packages/shared/kbn-std/src/parse_next_url.ts +++ b/src/platform/packages/shared/kbn-std/src/parse_next_url.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse } from 'url'; import { isInternalURL } from './is_internal_url'; const DEFAULT_NEXT_URL_QUERY_STRING_PARAMETER = 'next'; @@ -22,17 +21,16 @@ export function parseNextURL( basePath = '', nextUrlQueryParam = DEFAULT_NEXT_URL_QUERY_STRING_PARAMETER ) { - const { query, hash } = parse(href, true); - - let next = query[nextUrlQueryParam]; - if (!next) { + const parsedUrl = new URL(href, 'http://localhost'); + const nextValues = parsedUrl.searchParams.getAll(nextUrlQueryParam); + if (!nextValues.length) { return `${basePath}/`; } - if (Array.isArray(next) && next.length > 0) { - next = next[0]; - } else { - next = next as string; + const next = nextValues[0]; + + if (!next || !next.trim()) { + return `${basePath}/`; } // validate that `next` is not attempting a redirect to somewhere @@ -41,5 +39,5 @@ export function parseNextURL( return `${basePath}/`; } - return next + (hash || ''); + return next + parsedUrl.hash; } diff --git a/src/platform/packages/shared/kbn-std/src/url.test.ts b/src/platform/packages/shared/kbn-std/src/url.test.ts index 5853668f82124..4210aac136cfa 100644 --- a/src/platform/packages/shared/kbn-std/src/url.test.ts +++ b/src/platform/packages/shared/kbn-std/src/url.test.ts @@ -58,6 +58,14 @@ describe('modifyUrl()', () => { }) ).toEqual('mail:localhost'); }); + + test('preserves relative paths without adding a leading slash', () => { + expect(modifyUrl('a/b', (parsed) => parsed)).toEqual('a/b'); + }); + + test('preserves hash-only relative urls without adding a pathname', () => { + expect(modifyUrl('#/path?x=1', (parsed) => parsed)).toEqual('#/path?x=1'); + }); }); describe('isRelativeUrl()', () => { diff --git a/src/platform/packages/shared/kbn-std/src/url.ts b/src/platform/packages/shared/kbn-std/src/url.ts index e7b323ca558b4..5c257f4d386b9 100644 --- a/src/platform/packages/shared/kbn-std/src/url.ts +++ b/src/platform/packages/shared/kbn-std/src/url.ts @@ -8,9 +8,106 @@ */ import type { UrlObject } from 'url'; -import { format as formatUrl, parse as parseUrl } from 'url'; +import { format as formatUrl } from 'url'; import type { ParsedQuery } from 'query-string'; +const parseAbsoluteUrl = (url: string): URL | undefined => { + try { + return new URL(url); + } catch { + return undefined; + } +}; + +const parseProtocolRelativeUrl = (url: string): URL | undefined => { + if (!url.startsWith('//')) { + return undefined; + } + + try { + return new URL(`http:${url}`); + } catch { + return undefined; + } +}; + +const splitRelativeUrl = (url: string) => { + const hashIndex = url.indexOf('#'); + const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex); + const hash = hashIndex === -1 ? null : url.slice(hashIndex) || null; + const searchIndex = beforeHash.indexOf('?'); + const pathname = + searchIndex === -1 ? beforeHash || null : beforeHash.slice(0, searchIndex) || null; + const search = searchIndex === -1 ? null : beforeHash.slice(searchIndex) || null; + + return { hash, pathname, search }; +}; + +const parseQuery = (searchParams: URLSearchParams): ParsedQuery => { + const query: ParsedQuery = {}; + + for (const [key, value] of searchParams.entries()) { + const existingValue = query[key]; + + if (existingValue === undefined) { + query[key] = value; + continue; + } + + query[key] = Array.isArray(existingValue) + ? [...existingValue.filter((item): item is string => item !== null), value] + : existingValue === null + ? value + : [existingValue, value]; + } + + return query; +}; + +const formatAuth = (username: string, password: string): string | null => { + if (!username && !password) { + return null; + } + + if (!password) { + return decodeURIComponent(username); + } + + return `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; +}; + +const parseMeaningfulUrlParts = (url: string): URLMeaningfulParts => { + const absoluteUrl = parseAbsoluteUrl(url); + const protocolRelativeUrl = absoluteUrl ? undefined : parseProtocolRelativeUrl(url); + const parsedUrl = absoluteUrl ?? protocolRelativeUrl; + + if (!parsedUrl) { + const { hash, pathname, search } = splitRelativeUrl(url); + + return { + auth: null, + hash, + hostname: null, + pathname, + port: null, + protocol: null, + query: parseQuery(new URLSearchParams(search?.slice(1) ?? '')), + slashes: null, + }; + } + + return { + auth: formatAuth(parsedUrl.username, parsedUrl.password), + hash: parsedUrl.hash || null, + hostname: parsedUrl.hostname || null, + pathname: parsedUrl.pathname || null, + port: parsedUrl.port || null, + protocol: absoluteUrl ? parsedUrl.protocol || null : null, + query: parseQuery(parsedUrl.searchParams), + slashes: true, + }; +}; + /** * We define our own typings because the current version of @types/node * declares properties to be optional "hostname?: string". @@ -63,7 +160,15 @@ export function modifyUrl( url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | void ) { - const parsed = parseUrl(url, true) as URLMeaningfulParts; + if (typeof url !== 'string') { + throw new TypeError('Expected URL to be a string'); + } + + if (typeof urlModifier !== 'function') { + throw new TypeError('Expected urlModifier to be a function'); + } + + const parsed = parseMeaningfulUrlParts(url); // Copy over the most specific version of each property. By default, the parsed url includes several // conflicting properties (like path and pathname + search, or search and query) and keeping track @@ -102,28 +207,27 @@ export function modifyUrl( * @public */ export function isRelativeUrl(candidatePath: string) { - // validate that `candidatePath` is not attempting a redirect to somewhere - // outside of this Kibana install - const all = parseUrl(candidatePath, false /* parseQueryString */, true /* slashesDenoteHost */); - const { protocol, hostname, port } = all; - // We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not - // detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but - // browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser - // hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`) - // and the first slash that belongs to path. - if (protocol !== null || hostname !== null || port !== null) { + if (candidatePath.trimStart().startsWith('//')) { return false; } - return true; + + return parseAbsoluteUrl(candidatePath) === undefined; } /** * Returns the origin (protocol + host + port) from given `url` if `url` is a valid absolute url, or null otherwise */ export function getUrlOrigin(url: string): string | null { - const obj = parseUrl(url); - if (!obj.protocol && !obj.hostname) { + const parsedUrl = parseAbsoluteUrl(url); + + if (!parsedUrl?.protocol || !parsedUrl.hostname) { return null; } - return `${obj.protocol}//${obj.hostname}${obj.port ? `:${obj.port}` : ''}`; + + const authority = url.slice(parsedUrl.protocol.length + 2).split(/[/?#]/, 1)[0]; + const explicitPort = authority.match(/:(\d+)$/)?.[1]; + + return `${parsedUrl.protocol}//${parsedUrl.hostname}${ + explicitPort ? `:${explicitPort}` : parsedUrl.port ? `:${parsedUrl.port}` : '' + }`; } diff --git a/src/platform/packages/shared/kbn-synthtrace/src/cli/utils/get_service_urls.ts b/src/platform/packages/shared/kbn-synthtrace/src/cli/utils/get_service_urls.ts index e9faa7e6abfdd..28f02057ed663 100644 --- a/src/platform/packages/shared/kbn-synthtrace/src/cli/utils/get_service_urls.ts +++ b/src/platform/packages/shared/kbn-synthtrace/src/cli/utils/get_service_urls.ts @@ -7,14 +7,41 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Url } from 'url'; -import { format, parse } from 'url'; import { readKibanaConfig } from './read_kibana_config'; import type { Logger } from '../../lib/utils/create_logger'; import type { RunOptions } from './parse_run_cli_flags'; import { getFetchAgent } from './ssl'; import { getApiKeyHeader, getBasicAuthHeader } from './get_auth_header'; +const getAuth = (url: URL): string | undefined => { + if (!url.username && !url.password) { + return undefined; + } + + return `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; +}; + +const setAuth = (url: URL, auth?: string): URL => { + const nextUrl = new URL(url.toString()); + + if (!auth) { + return nextUrl; + } + + const separatorIndex = auth.indexOf(':'); + nextUrl.username = separatorIndex >= 0 ? auth.slice(0, separatorIndex) : auth; + nextUrl.password = separatorIndex >= 0 ? auth.slice(separatorIndex + 1) : ''; + + return nextUrl; +}; + +const stripAuth = (url: URL): URL => { + const nextUrl = new URL(url.toString()); + nextUrl.username = ''; + nextUrl.password = ''; + return nextUrl; +}; + async function getFetchStatus(url: string, apiKey?: string) { try { const parsedUrl = new URL(url); @@ -36,10 +63,7 @@ async function getFetchStatus(url: string, apiKey?: string) { function stripAuthIfCi(url: string) { if (process.env.CI?.toLowerCase() === 'true') { - return format({ - ...parse(url), - auth: undefined, - }); + return stripAuth(new URL(url)).toString(); } return url; } @@ -48,21 +72,20 @@ function stripTrailingSlash(url: string) { return url.replace(/\/$/, ''); } -async function discoverAuth(parsedTarget: Url) { +async function discoverAuth(parsedTarget: URL) { const possibleCredentials = [`admin:changeme`, `elastic:changeme`, `elastic_serverless:changeme`]; for (const auth of possibleCredentials) { - const url = format({ - ...parsedTarget, - auth, - }); + const url = setAuth(parsedTarget, auth); - const status = await getFetchStatus(url); + const status = await getFetchStatus(url.toString()); if (status === 200) { return auth; } } - throw new Error(`Failed to authenticate user for ${stripAuthIfCi(format(parsedTarget))}`); + throw new Error( + `Failed to authenticate user for ${stripAuthIfCi(stripAuth(parsedTarget).toString())}` + ); } async function getKibanaUrl({ @@ -147,16 +170,12 @@ async function getKibanaUrl({ } async function discoverTargetFromKibanaUrl(kibanaUrl: string) { - const suspectedParsedTargetUrl = parse(getTargetUrlFromKibana(kibanaUrl)); - - let targetAuth = suspectedParsedTargetUrl.auth; + const suspectedParsedTargetUrl = new URL(getTargetUrlFromKibana(kibanaUrl)); + let targetAuth = getAuth(suspectedParsedTargetUrl); let targetProtocol = suspectedParsedTargetUrl.protocol; - const urlWithSwitchedProtocol = parse( - format({ - ...suspectedParsedTargetUrl, - protocol: suspectedParsedTargetUrl.protocol === 'https:' ? 'http:' : 'https:', - }) - ); + const urlWithSwitchedProtocol = new URL(suspectedParsedTargetUrl.toString()); + urlWithSwitchedProtocol.protocol = + suspectedParsedTargetUrl.protocol === 'https:' ? 'http:' : 'https:'; const errorMessages = `Could not discover Elasticsearch URL based on Kibana URL ${stripAuthIfCi( kibanaUrl )}.`; @@ -175,8 +194,8 @@ async function discoverTargetFromKibanaUrl(kibanaUrl: string) { } } } else { - const status = await getFetchStatus(format(suspectedParsedTargetUrl)); - const statusWithSwitchedProtocol = await getFetchStatus(format(urlWithSwitchedProtocol)); + const status = await getFetchStatus(suspectedParsedTargetUrl.toString()); + const statusWithSwitchedProtocol = await getFetchStatus(urlWithSwitchedProtocol.toString()); if (status === 0 && statusWithSwitchedProtocol !== 0) { targetProtocol = urlWithSwitchedProtocol.protocol; } @@ -187,11 +206,10 @@ async function discoverTargetFromKibanaUrl(kibanaUrl: string) { } return stripTrailingSlash( - format({ - ...suspectedParsedTargetUrl, - auth: targetAuth, - protocol: targetProtocol, - }) + setAuth( + Object.assign(new URL(suspectedParsedTargetUrl.toString()), { protocol: targetProtocol }), + targetAuth + ).toString() ); } @@ -211,11 +229,11 @@ function discoverTargetFromKibanaConfig() { } const password = esConfig?.password; if (hosts) { - const parsed = parse(Array.isArray(hosts) ? hosts[0] : hosts); - return format({ - ...parsed, - auth: parsed.auth || (username && password ? `${username}:${password}` : undefined), - }); + const parsed = new URL(Array.isArray(hosts) ? hosts[0] : hosts); + return setAuth( + parsed, + getAuth(parsed) || (username && password ? `${username}:${password}` : undefined) + ).toString(); } } @@ -240,7 +258,7 @@ function getKibanaUrlFromTarget(target: string) { return esToKb; } -function logCertificateWarningsIfNeeded(parsedTarget: Url, parsedKibanaUrl: Url, logger: Logger) { +function logCertificateWarningsIfNeeded(parsedTarget: URL, parsedKibanaUrl: URL, logger: Logger) { if ( (parsedTarget.protocol === 'https:' || parsedKibanaUrl.protocol === 'https:') && (parsedTarget.hostname === '127.0.0.1' || parsedKibanaUrl.hostname === '127.0.0.1') @@ -268,9 +286,8 @@ export async function getServiceUrls({ } } - const parsedTarget = parse(target); - - let auth = parsedTarget.auth; + const parsedTarget = new URL(target); + let auth = getAuth(parsedTarget); let esHeaders; if (apiKey) { @@ -279,21 +296,13 @@ export async function getServiceUrls({ auth = await discoverAuth(parsedTarget); } - const formattedEsUrl = stripTrailingSlash( - format({ - ...parsedTarget, - auth, - }) - ); + const formattedEsUrl = stripTrailingSlash(setAuth(parsedTarget, auth).toString()); let targetKibanaUrl = kibana || getKibanaUrlFromTarget(formattedEsUrl); - const parsedKibanaUrl = parse(targetKibanaUrl); + const parsedKibanaUrl = new URL(targetKibanaUrl); if (!apiKey) { - targetKibanaUrl = format({ - ...parsedKibanaUrl, - auth, - }); + targetKibanaUrl = setAuth(parsedKibanaUrl, auth).toString(); } const { kibanaUrl, kibanaHeaders, username, password } = await getKibanaUrl({ diff --git a/src/platform/packages/shared/kbn-test-es-server/src/es_client_for_testing.ts b/src/platform/packages/shared/kbn-test-es-server/src/es_client_for_testing.ts index 511014b71ad5c..0ed2bd243d385 100644 --- a/src/platform/packages/shared/kbn-test-es-server/src/es_client_for_testing.ts +++ b/src/platform/packages/shared/kbn-test-es-server/src/es_client_for_testing.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import * as Url from 'url'; import * as Fs from 'fs'; import { CA_CERT_PATH } from '@kbn/dev-utils'; @@ -37,10 +36,12 @@ export function createEsClientForTesting(options: EsClientForTestingOptions) { } = options; const url = options.authOverride - ? Url.format({ - ...Url.parse(options.esUrl), - auth: `${options.authOverride.username}:${options.authOverride.password}`, - }) + ? (() => { + const parsedUrl = new URL(options.esUrl); + parsedUrl.username = options.authOverride.username; + parsedUrl.password = options.authOverride.password; + return parsedUrl.toString(); + })() : options.esUrl; return new EsClient({ diff --git a/src/platform/packages/shared/kbn-test-es-server/src/es_test_config.ts b/src/platform/packages/shared/kbn-test-es-server/src/es_test_config.ts index 638a4964e3ba7..111b69f877b7d 100644 --- a/src/platform/packages/shared/kbn-test-es-server/src/es_test_config.ts +++ b/src/platform/packages/shared/kbn-test-es-server/src/es_test_config.ts @@ -8,7 +8,7 @@ */ import { kibanaPackageJson as pkg } from '@kbn/repo-info'; -import Url from 'url'; +import { format as formatUrl } from 'url'; import { SYSTEM_INDICES_SUPERUSER } from '@kbn/es'; class EsTestConfig { @@ -21,7 +21,7 @@ class EsTestConfig { } getUrl() { - return Url.format(this.getUrlParts()); + return formatUrl(this.getUrlParts()); } getBuildFrom() { @@ -39,20 +39,30 @@ class EsTestConfig { getUrlParts() { // Allow setting one complete TEST_ES_URL for Es like https://elastic:changeme@myCloudInstance:9200 if (process.env.TEST_ES_URL) { - const testEsUrl = Url.parse(process.env.TEST_ES_URL); + const testEsUrl = new URL(process.env.TEST_ES_URL); if (!testEsUrl.port) { throw new Error( `process.env.TEST_ES_URL must contain port. given: ${process.env.TEST_ES_URL}` ); } + const username = + testEsUrl.username === '' ? undefined : decodeURIComponent(testEsUrl.username); + const password = + testEsUrl.password === '' ? undefined : decodeURIComponent(testEsUrl.password); + return { // have to remove the ":" off protocol - protocol: testEsUrl.protocol?.slice(0, -1), + protocol: testEsUrl.protocol.slice(0, -1), hostname: testEsUrl.hostname, port: parseInt(testEsUrl.port, 10), - username: testEsUrl.auth?.split(':')[0], - password: testEsUrl.auth?.split(':')[1], - auth: testEsUrl.auth, + username, + password, + auth: + username === undefined && password === undefined + ? undefined + : password === undefined + ? username + : `${username ?? ''}:${password}`, }; } diff --git a/src/platform/packages/shared/kbn-test/kbn_test_config.test.ts b/src/platform/packages/shared/kbn-test/kbn_test_config.test.ts new file mode 100644 index 0000000000000..768b25ecd2381 --- /dev/null +++ b/src/platform/packages/shared/kbn-test/kbn_test_config.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { kbnTestConfig } from './kbn_test_config'; + +describe('kbnTestConfig', () => { + const originalTestKibanaUrl = process.env.TEST_KIBANA_URL; + + afterEach(() => { + if (originalTestKibanaUrl === undefined) { + delete process.env.TEST_KIBANA_URL; + } else { + process.env.TEST_KIBANA_URL = originalTestKibanaUrl; + } + }); + + it('parses TEST_KIBANA_URL with credentials', () => { + process.env.TEST_KIBANA_URL = 'https://elastic:changeme@example.com:9200'; + + expect(kbnTestConfig.getUrlParts()).toEqual({ + protocol: 'https', + hostname: 'example.com', + port: 9200, + auth: 'elastic:changeme', + username: 'elastic', + password: 'changeme', + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-test/kbn_test_config.ts b/src/platform/packages/shared/kbn-test/kbn_test_config.ts index 1faa3291f590e..7d33942279ff7 100644 --- a/src/platform/packages/shared/kbn-test/kbn_test_config.ts +++ b/src/platform/packages/shared/kbn-test/kbn_test_config.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import url from 'url'; +import { URL } from 'node:url'; import { kibanaTestUser } from './src/kbn/users'; export interface UrlParts { @@ -32,14 +32,24 @@ export const kbnTestConfig = new (class KbnTestConfig { getUrlParts(user: UserAuth = kibanaTestUser): UrlParts { // allow setting one complete TEST_KIBANA_URL for ES like https://elastic:changeme@example.com:9200 if (process.env.TEST_KIBANA_URL) { - const testKibanaUrl = url.parse(process.env.TEST_KIBANA_URL); + const testKibanaUrl = new URL(process.env.TEST_KIBANA_URL); + const username = + testKibanaUrl.username === '' ? undefined : decodeURIComponent(testKibanaUrl.username); + const password = + testKibanaUrl.password === '' ? undefined : decodeURIComponent(testKibanaUrl.password); + return { - protocol: testKibanaUrl.protocol?.slice(0, -1), - hostname: testKibanaUrl.hostname === null ? undefined : testKibanaUrl.hostname, + protocol: testKibanaUrl.protocol.slice(0, -1), + hostname: testKibanaUrl.hostname || undefined, port: testKibanaUrl.port ? parseInt(testKibanaUrl.port, 10) : undefined, - auth: testKibanaUrl.auth === null ? undefined : testKibanaUrl.auth, - username: testKibanaUrl.auth?.split(':')[0], - password: testKibanaUrl.auth?.split(':')[1], + auth: + username === undefined && password === undefined + ? undefined + : password === undefined + ? username + : `${username ?? ''}:${password}`, + username, + password, }; } diff --git a/src/platform/packages/shared/kbn-test/src/ftr_es_client.ts b/src/platform/packages/shared/kbn-test/src/ftr_es_client.ts index 89a08bce87f72..f9fb669879c0a 100644 --- a/src/platform/packages/shared/kbn-test/src/ftr_es_client.ts +++ b/src/platform/packages/shared/kbn-test/src/ftr_es_client.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import * as Url from 'url'; +import { format as formatUrl } from 'url'; import { createEsClientForTesting, type EsClientForTestingOptions } from '@kbn/test-es-server'; import type { Config } from './functional_test_runner'; @@ -34,7 +34,7 @@ export function createEsClientForFtrConfig( config: Config, overrides?: Omit ) { - const esUrl = Url.format(config.get('servers.elasticsearch')); + const esUrl = formatUrl(config.get('servers.elasticsearch')); return createEsClientForTesting({ esUrl, requestTimeout: config.get('timeouts.esRequestTimeout'), diff --git a/src/platform/packages/shared/kbn-workspaces/src/exec.ts b/src/platform/packages/shared/kbn-workspaces/src/exec.ts index 935a41dbf664f..2008f3fa67889 100644 --- a/src/platform/packages/shared/kbn-workspaces/src/exec.ts +++ b/src/platform/packages/shared/kbn-workspaces/src/exec.ts @@ -44,11 +44,8 @@ export async function exec( let child: execa.ExecaChildProcess; if (args.length === 2) { - // A single command string is passed, which may contain shell-specific syntax like `&&` or `||`. - // To ensure these are interpreted correctly, we must use a shell. - child = execa.command(args[0], { ...execaOpts, shell: true }); - - log.debug(`Running command with shell: ${args[0]} in ${cwd}`); + child = execa.command(args[0], { ...execaOpts }); + log.debug(`Running command: ${args[0]} in ${cwd}`); } else { // A file and arguments array are passed, so we can execute it directly without a shell. child = execa(args[0], args[1], { ...execaOpts }); diff --git a/src/platform/plugins/shared/console/server/lib/elasticsearch_proxy_config.ts b/src/platform/plugins/shared/console/server/lib/elasticsearch_proxy_config.ts index 481e738e01bfe..8d6631fe97c8c 100644 --- a/src/platform/plugins/shared/console/server/lib/elasticsearch_proxy_config.ts +++ b/src/platform/plugins/shared/console/server/lib/elasticsearch_proxy_config.ts @@ -10,12 +10,11 @@ import _ from 'lodash'; import http from 'http'; import https from 'https'; -import url from 'url'; import type { ESConfigForProxy } from '../types'; const createAgent = (legacyConfig: ESConfigForProxy) => { - const target = url.parse(_.head(legacyConfig.hosts)!); + const target = new URL(_.head(legacyConfig.hosts)!); if (!/^https/.test(target.protocol || '')) return new http.Agent(); const agentOptions: https.AgentOptions = {}; diff --git a/src/platform/plugins/shared/kibana_utils/common/state_management/format.ts b/src/platform/plugins/shared/kibana_utils/common/state_management/format.ts index fe4ce36d75854..5c9445a10dc00 100644 --- a/src/platform/plugins/shared/kibana_utils/common/state_management/format.ts +++ b/src/platform/plugins/shared/kibana_utils/common/state_management/format.ts @@ -18,9 +18,6 @@ export function replaceUrlQuery( queryReplacer: (query: ParsedQuery) => ParsedQuery ) { const url = parseUrl(rawUrl); - // @ts-expect-error `queryReplacer` expects key/value pairs with values of type `string | string[] | null`, - // however `@types/node` says that `url.query` has values of type `string | string[] | undefined`. - // After investigating this, it seems that no matter what the values will be of type `string | string[]` const newQuery = queryReplacer(url.query || {}); const searchQueryString = stringify(urlUtils.encodeQuery(newQuery), { sort: false, @@ -39,9 +36,6 @@ export function replaceUrlHashQuery( ) { const url = parseUrl(rawUrl); const hash = parseUrlHash(rawUrl); - // @ts-expect-error `queryReplacer` expects key/value pairs with values of type `string | string[] | null`, - // however `@types/node` says that `url.query` has values of type `string | string[] | undefined`. - // After investigating this, it seems that no matter what the values will be of type `string | string[]` const newQuery = queryReplacer(hash?.query || {}); const searchQueryString = stringify(urlUtils.encodeQuery(newQuery), { sort: false, diff --git a/src/platform/plugins/shared/kibana_utils/common/state_management/parse.ts b/src/platform/plugins/shared/kibana_utils/common/state_management/parse.ts index 29f0dd6543632..405e941aa02d9 100644 --- a/src/platform/plugins/shared/kibana_utils/common/state_management/parse.ts +++ b/src/platform/plugins/shared/kibana_utils/common/state_management/parse.ts @@ -7,9 +7,126 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse as _parseUrl } from 'url'; +import type { ParsedQuery } from 'query-string'; -export const parseUrl = (url: string) => _parseUrl(url, true); +interface ParsedUrl { + auth: string | null; + hash: string | null; + host: string | null; + hostname: string | null; + path: string | null; + pathname: string | null; + port: string | null; + protocol: string | null; + query: ParsedQuery; + search: string | null; + slashes: boolean | null; +} + +const parseAbsoluteUrl = (url: string): URL | undefined => { + try { + return new URL(url); + } catch { + return undefined; + } +}; + +const parseProtocolRelativeUrl = (url: string): URL | undefined => { + if (!url.startsWith('//')) { + return undefined; + } + + try { + return new URL(`http:${url}`); + } catch { + return undefined; + } +}; + +const splitRelativeUrl = (url: string) => { + const hashIndex = url.indexOf('#'); + const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex); + const hash = hashIndex === -1 ? null : url.slice(hashIndex) || null; + const searchIndex = beforeHash.indexOf('?'); + const pathname = + searchIndex === -1 ? beforeHash || null : beforeHash.slice(0, searchIndex) || null; + const search = searchIndex === -1 ? null : beforeHash.slice(searchIndex) || null; + + return { hash, pathname, search }; +}; + +const parseQuery = (searchParams: URLSearchParams): ParsedQuery => { + const query: ParsedQuery = {}; + + for (const [key, value] of searchParams.entries()) { + const existingValue = query[key]; + + if (existingValue === undefined) { + query[key] = value; + continue; + } + + query[key] = Array.isArray(existingValue) + ? [...existingValue.filter((item): item is string => item !== null), value] + : existingValue === null + ? value + : [existingValue, value]; + } + + return query; +}; + +const formatAuth = (username: string, password: string): string | null => { + if (!username && !password) { + return null; + } + + if (!password) { + return decodeURIComponent(username); + } + + return `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; +}; + +export const parseUrl = (url: string): ParsedUrl => { + const absoluteUrl = parseAbsoluteUrl(url); + const protocolRelativeUrl = absoluteUrl ? undefined : parseProtocolRelativeUrl(url); + const parsedUrl = absoluteUrl ?? protocolRelativeUrl; + + if (!parsedUrl) { + const { hash, pathname, search } = splitRelativeUrl(url); + + return { + auth: null, + hash, + host: null, + hostname: null, + path: pathname || search ? `${pathname ?? ''}${search ?? ''}` : null, + pathname, + port: null, + protocol: null, + query: parseQuery(new URLSearchParams(search?.slice(1) ?? '')), + search, + slashes: null, + }; + } + + const search = parsedUrl.search || null; + + return { + auth: formatAuth(parsedUrl.username, parsedUrl.password), + hash: parsedUrl.hash || null, + host: parsedUrl.host || null, + hostname: parsedUrl.hostname || null, + path: parsedUrl.pathname || search ? `${parsedUrl.pathname}${parsedUrl.search}` : null, + pathname: parsedUrl.pathname || null, + port: parsedUrl.port || null, + protocol: absoluteUrl ? parsedUrl.protocol || null : null, + query: parseQuery(parsedUrl.searchParams), + search, + slashes: true, + }; +}; export const parseUrlHash = (url: string) => { const hash = parseUrl(url).hash; diff --git a/src/platform/plugins/shared/kibana_utils/common/state_management/set_state_to_kbn_url.test.ts b/src/platform/plugins/shared/kibana_utils/common/state_management/set_state_to_kbn_url.test.ts index d71af27c68cc4..165c4cc71d02c 100644 --- a/src/platform/plugins/shared/kibana_utils/common/state_management/set_state_to_kbn_url.test.ts +++ b/src/platform/plugins/shared/kibana_utils/common/state_management/set_state_to_kbn_url.test.ts @@ -103,5 +103,21 @@ describe('set_state_to_kbn_url', () => { `"http://localhost:5601/oxf/app/kibana/yourApp?_a=(tab:other)&_b=(f:test,i:'',l:'')"` ); }); + + it('preserves hash-based relative urls without adding a leading slash', () => { + const newUrl = setStateToKbnUrl('_g', {}, { useHash: false }, '#/create'); + expect(newUrl).toBe('#/create?_g=()'); + }); + + it('preserves app paths without adding a trailing slash before the query', () => { + const newUrl = setStateToKbnUrl( + '_a', + { tab: 'other' }, + { useHash: false, storeInHashQuery: false }, + '/app/management/ml/anomaly_detection' + ); + + expect(newUrl).toBe('/app/management/ml/anomaly_detection?_a=(tab:other)'); + }); }); }); diff --git a/src/platform/plugins/shared/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/platform/plugins/shared/kibana_utils/public/state_management/url/kbn_url_storage.ts index 5abcf5ae6d31e..246becf942292 100644 --- a/src/platform/plugins/shared/kibana_utils/public/state_management/url/kbn_url_storage.ts +++ b/src/platform/plugins/shared/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -265,16 +265,10 @@ export function getRelativeToHistoryPath(absoluteUrl: string, history: History): return formatUrl({ pathname: stripBasename(parsedUrl.pathname ?? null), - // @ts-expect-error `urlUtils.encodeQuery` expects key/value pairs with values of type `string | string[] | null`, - // however `@types/node` says that `url.query` has values of type `string | string[] | undefined`. - // After investigating this, it seems that no matter what the values will be of type `string | string[]` search: stringify(urlUtils.encodeQuery(parsedUrl.query), { sort: false, encode: false }), hash: parsedHash ? formatUrl({ pathname: parsedHash.pathname, - // @ts-expect-error `urlUtils.encodeQuery` expects key/value pairs with values of type `string | string[] | null`, - // however `@types/node` says that `url.query` has values of type `string | string[] | undefined`. - // After investigating this, it seems that no matter what the values will be of type `string | string[]` search: stringify(urlUtils.encodeQuery(parsedHash.query), { sort: false, encode: false }), }) : parsedUrl.hash, diff --git a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx index 590c393991198..dfdab7e3afc6d 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.test.tsx @@ -105,7 +105,7 @@ describe('Share modal embed content tab', () => { await waitFor(() => { expect(copyButton.getAttribute('data-share-url')).toBe( - '' + '' ); }); }); diff --git a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx index 0db455fed5c5e..b4f191c42c260 100644 --- a/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx +++ b/src/platform/plugins/shared/share/public/components/tabs/embed/embed_content.tsx @@ -24,7 +24,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useEffect, useState, useRef } from 'react'; -import { format as formatUrl, parse as parseUrl } from 'url'; import type { AnonymousAccessState } from '../../../../common'; import { useShareContext, type IShareContext } from '../../context'; @@ -154,31 +153,24 @@ export const EmbedContent = ({ ? updateUrlParams(shareableUrlForSavedObject) : snapshotUrl; - const parsedUrl = parseUrl(tempUrl); + const parsedUrl = new URL(tempUrl, window.location.href); - if (!parsedUrl || !parsedUrl.hash) { + if (!parsedUrl.hash) { return tempUrl; } // Get the application route, after the hash, and remove the #. - const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); - - const formattedUrl = formatUrl({ - protocol: parsedUrl.protocol, - auth: parsedUrl.auth, - host: parsedUrl.host, - pathname: parsedUrl.pathname, - hash: formatUrl({ - pathname: parsedAppUrl.pathname, - query: { - // Add global state to the URL so that the iframe doesn't just show the time range - // default. - _g: parsedAppUrl.query._g, - }, - }), - }); + const parsedAppUrl = new URL(parsedUrl.hash.slice(1), window.location.href); + const appHashSearchParams = new URLSearchParams(); + const globalState = parsedAppUrl.searchParams.get('_g'); + + appHashSearchParams.set('_g', globalState ?? ''); + + parsedUrl.hash = `${parsedAppUrl.pathname}${ + appHashSearchParams.size > 0 ? `?${appHashSearchParams.toString()}` : '' + }`; - return updateUrlParams(formattedUrl); + return updateUrlParams(parsedUrl.toString()); }, [shareableUrlForSavedObject, snapshotUrl, updateUrlParams]); const createShortUrl = useCallback(async () => { diff --git a/src/platform/plugins/shared/share/public/components/url_panel_content.tsx b/src/platform/plugins/shared/share/public/components/url_panel_content.tsx index 03460edb9dd12..3c89de43af614 100644 --- a/src/platform/plugins/shared/share/public/components/url_panel_content.tsx +++ b/src/platform/plugins/shared/share/public/components/url_panel_content.tsx @@ -25,8 +25,6 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; -import { format as formatUrl, parse as parseUrl } from 'url'; - import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { Capabilities } from '@kbn/core/public'; @@ -213,29 +211,25 @@ class UrlPanelContentComponent extends Component 0 ? `?${appHashSearchParams.toString()}` : '' + }`; + + return this.updateUrlParams(parsedUrl.toString()); }; private getSnapshotUrl = (forSavedObject?: boolean) => { diff --git a/src/platform/plugins/shared/share/public/url_service/short_urls/short_url_client.ts b/src/platform/plugins/shared/share/public/url_service/short_urls/short_url_client.ts index 33c68f3bbf452..72311d59c1acf 100644 --- a/src/platform/plugins/shared/share/public/url_service/short_urls/short_url_client.ts +++ b/src/platform/plugins/shared/share/public/url_service/short_urls/short_url_client.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { parse as parseUrl } from 'url'; import type { SerializableRecord } from '@kbn/utility-types'; import { convertRelativeTimeStringToAbsoluteTimeString } from '../../lib/time_utils'; import type { LegacyShortUrlLocatorParams } from '../../../common/url_service/locators/legacy_short_url_locator'; @@ -130,14 +129,12 @@ export class BrowserShortUrlClient implements IShortUrlClient { longUrl: string, isAbsoluteTime?: boolean ): Promise { - const parsedUrl = parseUrl(longUrl); - - if (!parsedUrl || !parsedUrl.path) { - throw new Error(`Invalid URL: ${longUrl}`); - } - - const path = parsedUrl.path.replace(this.dependencies.http.basePath.get(), ''); - const hash = parsedUrl.hash ? parsedUrl.hash : ''; + const parsedUrl = new URL(longUrl, window.location.href); + const path = `${parsedUrl.pathname}${parsedUrl.search}`.replace( + this.dependencies.http.basePath.get(), + '' + ); + const hash = parsedUrl.hash || ''; const relativeUrl = path + hash; const locator = this.dependencies.locators.get( LEGACY_SHORT_URL_LOCATOR_ID diff --git a/src/platform/plugins/shared/vis_types/timeseries/public/application/components/vis_types/table/vis.js b/src/platform/plugins/shared/vis_types/timeseries/public/application/components/vis_types/table/vis.js index 94cf2fe5210f7..896d967063c6b 100644 --- a/src/platform/plugins/shared/vis_types/timeseries/public/application/components/vis_types/table/vis.js +++ b/src/platform/plugins/shared/vis_types/timeseries/public/application/components/vis_types/table/vis.js @@ -9,7 +9,6 @@ import _, { isArray, last, get } from 'lodash'; import React, { Component } from 'react'; -import { parse as parseUrl } from 'url'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { getMetricsField } from '../../lib/get_metrics_field'; @@ -48,7 +47,7 @@ function getColor(rules, colorKey, value) { } function sanitizeUrl(url) { - const { protocol } = parseUrl(url); + const { protocol } = new URL(url, window.location.href); // eslint-disable-next-line no-script-url if (protocol === 'javascript:' || protocol === 'data:' || protocol === 'vbscript:') { return ''; diff --git a/src/platform/test/functional/apps/discover/group5/_shared_links.ts b/src/platform/test/functional/apps/discover/group5/_shared_links.ts index 02743411cb664..10f98d3599a51 100644 --- a/src/platform/test/functional/apps/discover/group5/_shared_links.ts +++ b/src/platform/test/functional/apps/discover/group5/_shared_links.ts @@ -93,7 +93,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.waitFor('url to contain default sorting', async () => { // url fallback default sort should have been pushed to URL const url = await browser.getCurrentUrl(); - return url.includes('sort:!(!(%27@timestamp%27,desc))'); + return decodeURIComponent(url).includes("sort:!(!('@timestamp',desc))"); }); await retry.waitFor('document table to contain the right timestamp', async () => { diff --git a/src/platform/test/plugin_functional/test_suites/application_links/redirect_app_links.ts b/src/platform/test/plugin_functional/test_suites/application_links/redirect_app_links.ts index be28044357c0b..8b385c3850797 100644 --- a/src/platform/test/plugin_functional/test_suites/application_links/redirect_app_links.ts +++ b/src/platform/test/plugin_functional/test_suites/application_links/redirect_app_links.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import url from 'url'; import expect from '@kbn/expect'; import type { PluginFunctionalProviderContext } from '../../services'; import '@kbn/core-app-status-plugin/public/types'; @@ -19,8 +18,8 @@ declare global { } const getPathWithHash = (absoluteUrl: string) => { - const parsed = url.parse(absoluteUrl); - return `${parsed.path}${parsed.hash ?? ''}`; + const parsed = new URL(absoluteUrl); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; }; export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/application_status.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/application_status.ts index dbaa0d468c843..dd86c2bd9d46a 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/application_status.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -7,21 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Url from 'url'; import expect from '@kbn/expect'; import type { AppUpdatableFields } from '@kbn/core-application-browser'; import { AppStatus } from '@kbn/core-application-browser'; import type { PluginFunctionalProviderContext } from '../../services'; import '@kbn/core-app-status-plugin/public/types'; -const getKibanaUrl = (pathname?: string, search?: string) => - Url.format({ - protocol: 'http:', - hostname: process.env.TEST_KIBANA_HOST || 'localhost', - port: process.env.TEST_KIBANA_PORT || '5620', - pathname, - search, - }); +const getKibanaUrl = (pathname?: string, search?: string) => { + const url = new URL( + pathname ?? '/', + `http://${process.env.TEST_KIBANA_HOST || 'localhost'}:${ + process.env.TEST_KIBANA_PORT || '5620' + }` + ); + + if (search !== undefined) { + url.search = search; + } + + return pathname === undefined && search === undefined ? url.origin : url.toString(); +}; export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { const PageObjects = getPageObjects(['common']); @@ -101,7 +106,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide await navigateToApp('app_status'); expect(await testSubjects.exists('appStatusApp')).to.eql(true); const currentUrl = await browser.getCurrentUrl(); - expect(Url.parse(currentUrl).pathname).to.eql('/app/app_status/arbitrary/path'); + expect(new URL(currentUrl).pathname).to.eql('/app/app_status/arbitrary/path'); }); it('can change the state of the currently mounted app', async () => { diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts index 3e1d7781375c7..7c5979d136914 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import url from 'url'; import expect from '@kbn/expect'; import type { PluginFunctionalProviderContext } from '../../services'; @@ -18,8 +17,8 @@ declare global { } const getPathWithHash = (absoluteUrl: string) => { - const parsed = url.parse(absoluteUrl); - return `${parsed.path}${parsed.hash ?? ''}`; + const parsed = new URL(absoluteUrl); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; }; export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { diff --git a/src/platform/test/server_integration/http/platform/headers.ts b/src/platform/test/server_integration/http/platform/headers.ts index 30cca9f2d2201..1c21518d79c3c 100644 --- a/src/platform/test/server_integration/http/platform/headers.ts +++ b/src/platform/test/server_integration/http/platform/headers.ts @@ -8,7 +8,6 @@ */ import Http from 'http'; -import Url from 'url'; import { getUrl } from '@kbn/test'; import type { FtrProviderContext } from '../../services/types'; @@ -23,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { const agent = new Http.Agent({ keepAlive: true, }); - const { protocol, hostname, port } = Url.parse(getUrl.baseUrl(config.get('servers.kibana'))); + const { protocol, hostname, port } = new URL(getUrl.baseUrl(config.get('servers.kibana'))); function performRequest() { return new Promise((resolve, reject) => { diff --git a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts index 64064116c46b7..bd4c87f8394d3 100644 --- a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts +++ b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts @@ -6,9 +6,8 @@ */ import { Client } from '@elastic/elasticsearch'; import { compact } from 'lodash'; -import { format, parse } from 'node:url'; import Path from 'path'; -import type { UrlWithParsedQuery } from 'url'; +import type { UrlObject } from 'url'; import { FetchResponseError } from './kibana_fetch_response_error'; import { createProxyTransport } from './proxy_transport'; import { getInternalKibanaHeaders } from './get_internal_kibana_headers'; @@ -42,15 +41,16 @@ function combineSignal(left: AbortSignal, right?: AbortSignal | null | undefined export class KibanaClient { public readonly es: Client; constructor(private readonly options: KibanaClientOptions) { - const parsedBaseUrl = parse(options.baseUrl, true); - - const [username, password] = (parsedBaseUrl.auth ?? '').split(':'); - - const node = format({ - ...parsedBaseUrl, - auth: null, - pathname: null, - }); + const parsedBaseUrl = new URL(options.baseUrl); + const username = decodeURIComponent(parsedBaseUrl.username); + const password = decodeURIComponent(parsedBaseUrl.password); + const nodeUrl = new URL(parsedBaseUrl.toString()); + nodeUrl.username = ''; + nodeUrl.password = ''; + nodeUrl.pathname = ''; + nodeUrl.search = ''; + nodeUrl.hash = ''; + const node = nodeUrl.toString().replace(/\/$/, ''); this.es = new Client({ auth: { @@ -59,7 +59,7 @@ export class KibanaClient { }, node, Transport: createProxyTransport({ - pathname: parsedBaseUrl.pathname!, + pathname: parsedBaseUrl.pathname, headers: getInternalKibanaHeaders(), }), }); @@ -76,39 +76,52 @@ export class KibanaClient { options: FetchInputOptions, init?: FetchInitOptions & { asRawResponse?: boolean } ): Promise { - const urlObject = - typeof options === 'string' - ? { - pathname: options, - } - : options; - - const formattedBaseUrl = parse(this.options.baseUrl, true); - - const urlOptions: UrlWithParsedQuery = { - ...formattedBaseUrl, - ...urlObject, - pathname: Path.posix.join( - ...compact([ - '/', - formattedBaseUrl.pathname, - ...(this.options.spaceId ? ['s', this.options.spaceId] : []), - urlObject.pathname, - ]) - ), - auth: null, - }; + const baseUrl = new URL(this.options.baseUrl); + const requestUrl = typeof options === 'string' ? undefined : options; + const requestPathname = typeof options === 'string' ? options : options.pathname; + const url = new URL(baseUrl.toString()); + url.pathname = Path.posix.join( + ...compact([ + '/', + baseUrl.pathname, + ...(this.options.spaceId ? ['s', this.options.spaceId] : []), + requestPathname, + ]) + ); + url.search = requestUrl?.search ?? ''; + url.hash = ''; const body = init?.body ? JSON.stringify(init?.body) : undefined; - const response = await fetch(format(urlOptions), { + const headers: Record = { + ['content-type']: 'application/json', + ...getInternalKibanaHeaders(), + ...((baseUrl.username || baseUrl.password) && { + Authorization: `Basic ${Buffer.from( + `${decodeURIComponent(baseUrl.username)}:${decodeURIComponent(baseUrl.password)}` + ).toString('base64')}`, + }), + ...(init?.headers as Record | undefined), + }; + + const query = (requestUrl as unknown as { query?: UrlObject['query'] } | undefined)?.query; + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value == null) { + continue; + } + + if (Array.isArray(value)) { + value.forEach((item) => url.searchParams.append(key, String(item))); + } else { + url.searchParams.set(key, String(value)); + } + } + } + + const response = await fetch(url.toString(), { ...init, - headers: { - ['content-type']: 'application/json', - ...getInternalKibanaHeaders(), - Authorization: `Basic ${Buffer.from(formattedBaseUrl.auth!).toString('base64')}`, - ...init?.headers, - }, + headers, signal: combineSignal(this.options.signal, init?.signal), body, }); diff --git a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/discover_kibana_url.ts b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/discover_kibana_url.ts index f0ef150afe24a..807b0c9a47cd0 100644 --- a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/discover_kibana_url.ts +++ b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/discover_kibana_url.ts @@ -6,27 +6,63 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import { omit } from 'lodash'; -import type { Url } from 'url'; -import { format, parse } from 'url'; import { getInternalKibanaHeaders } from './get_internal_kibana_headers'; -async function discoverAuth(parsedTarget: Url, log: ToolingLog) { +const getAuth = (url: URL): string | undefined => { + if (!url.username && !url.password) { + return undefined; + } + + return `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; +}; + +const setAuth = (url: URL, auth?: string): URL => { + const nextUrl = new URL(url.toString()); + + if (!auth) { + return nextUrl; + } + + const separatorIndex = auth.indexOf(':'); + nextUrl.username = separatorIndex >= 0 ? auth.slice(0, separatorIndex) : auth; + nextUrl.password = separatorIndex >= 0 ? auth.slice(separatorIndex + 1) : ''; + + return nextUrl; +}; + +const stripAuth = (url: URL): URL => { + const nextUrl = new URL(url.toString()); + nextUrl.username = ''; + nextUrl.password = ''; + return nextUrl; +}; + +const getAuthHeaders = (url: URL): Record => { + const auth = getAuth(url); + + if (!auth) { + return {}; + } + + return { Authorization: `Basic ${Buffer.from(auth).toString('base64')}` }; +}; + +async function discoverAuth(parsedTarget: URL, log: ToolingLog) { const possibleCredentials = [`elastic:changeme`, `admin:changeme`]; for (const auth of possibleCredentials) { - const url = format({ - ...parsedTarget, - auth, - }); + const url = setAuth(parsedTarget, auth); let status: number; try { - log.debug(`Fetching ${url}`); - const response = await fetch(url, { - headers: getInternalKibanaHeaders(), + log.debug(`Fetching ${stripAuth(url)}`); + const response = await fetch(stripAuth(url).toString(), { + headers: { + ...getInternalKibanaHeaders(), + ...getAuthHeaders(url), + }, }); status = response.status; } catch (err) { - log.debug(`${url} resulted in ${err.message}`); + log.debug(`${stripAuth(url)} resulted in ${err.message}`); status = 0; } @@ -35,27 +71,23 @@ async function discoverAuth(parsedTarget: Url, log: ToolingLog) { } } - throw new Error(`Failed to authenticate user for ${format(parsedTarget)}`); + throw new Error(`Failed to authenticate user for ${stripAuth(parsedTarget)}`); } async function getKibanaApiUrl({ baseUrl, log }: { baseUrl: string; log: ToolingLog }) { try { const isCI = process.env.CI?.toLowerCase() === 'true'; - - const parsedKibanaUrl = parse(baseUrl); - - const kibanaUrlWithoutAuth = format(omit(parsedKibanaUrl, 'auth')); + const parsedKibanaUrl = new URL(baseUrl); + const kibanaUrlWithoutAuth = stripAuth(parsedKibanaUrl); log.debug(`Checking Kibana URL ${kibanaUrlWithoutAuth} for a redirect`); const headers = { ...getInternalKibanaHeaders(), - ...(parsedKibanaUrl.auth - ? { Authorization: `Basic ${Buffer.from(parsedKibanaUrl.auth).toString('base64')}` } - : {}), + ...getAuthHeaders(parsedKibanaUrl), }; - const unredirectedResponse = await fetch(kibanaUrlWithoutAuth, { + const unredirectedResponse = await fetch(kibanaUrlWithoutAuth.toString(), { headers, method: 'HEAD', redirect: 'manual', @@ -63,24 +95,19 @@ async function getKibanaApiUrl({ baseUrl, log }: { baseUrl: string; log: Tooling log.debug('Unredirected response', unredirectedResponse.headers.get('location')); - const discoveredKibanaUrl = + const discoveredKibanaUrl = new URL( unredirectedResponse.headers .get('location') ?.replace('/spaces/enter', '') - ?.replace('spaces/space_selector', '') || kibanaUrlWithoutAuth; + ?.replace('spaces/space_selector', '') || kibanaUrlWithoutAuth.toString(), + kibanaUrlWithoutAuth + ); log.debug(`Discovered Kibana URL at ${discoveredKibanaUrl}`); - const parsedTarget = parse(baseUrl); + const discoveredKibanaUrlWithAuth = setAuth(discoveredKibanaUrl, getAuth(parsedKibanaUrl)); - const parsedDiscoveredUrl = parse(discoveredKibanaUrl); - - const discoveredKibanaUrlWithAuth = format({ - ...parsedDiscoveredUrl, - auth: parsedTarget.auth, - }); - - const redirectedResponse = await fetch(discoveredKibanaUrlWithAuth, { + const redirectedResponse = await fetch(stripAuth(discoveredKibanaUrlWithAuth).toString(), { method: 'HEAD', headers, }); @@ -91,10 +118,7 @@ async function getKibanaApiUrl({ baseUrl, log }: { baseUrl: string; log: Tooling ); } - const discoveredKibanaUrlWithoutAuth = format({ - ...parsedDiscoveredUrl, - auth: undefined, - }); + const discoveredKibanaUrlWithoutAuth = stripAuth(discoveredKibanaUrlWithAuth); log.info( `Discovered kibana running at: ${ @@ -102,7 +126,7 @@ async function getKibanaApiUrl({ baseUrl, log }: { baseUrl: string; log: Tooling }` ); - return discoveredKibanaUrlWithAuth.replace(/\/$/, ''); + return discoveredKibanaUrlWithAuth.toString().replace(/\/$/, ''); } catch (error) { throw new Error(`Could not connect to Kibana: ` + error.message); } @@ -119,9 +143,11 @@ export async function discoverKibanaUrl({ }) { baseUrl = baseUrl ?? 'http://127.0.0.1:5601'; - const parsedTarget = parse(baseUrl); + const parsedTarget = new URL(baseUrl); - let authToUse = auth?.basic ? `${auth.basic.username}:${auth.basic.password}` : parsedTarget.auth; + let authToUse = auth?.basic + ? `${auth.basic.username}:${auth.basic.password}` + : getAuth(parsedTarget); if (!authToUse) { authToUse = await discoverAuth(parsedTarget, log); @@ -129,12 +155,7 @@ export async function discoverKibanaUrl({ const suspectedKibanaUrl = baseUrl; - const parsedKibanaUrl = parse(suspectedKibanaUrl); - - const kibanaUrlWithAuth = format({ - ...parsedKibanaUrl, - auth: authToUse, - }); + const kibanaUrlWithAuth = setAuth(new URL(suspectedKibanaUrl), authToUse).toString(); const validatedKibanaUrl = await getKibanaApiUrl({ baseUrl: kibanaUrlWithAuth, log }); diff --git a/x-pack/platform/plugins/private/graph/public/state_management/url_templates.ts b/x-pack/platform/plugins/private/graph/public/state_management/url_templates.ts index 4f51f95bbc536..6f6c7cd4ec632 100644 --- a/x-pack/platform/plugins/private/graph/public/state_management/url_templates.ts +++ b/x-pack/platform/plugins/private/graph/public/state_management/url_templates.ts @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; import { modifyUrl } from '@kbn/std'; import rison from '@kbn/rison'; import { takeEvery } from 'redux-saga/effects'; -import { format, parse } from 'url'; import type { GraphState, GraphStoreDependencies } from './store'; import type { UrlTemplate } from '../types'; import { reset } from './global'; @@ -46,14 +45,8 @@ function generateDefaultTemplate( sort: ['_score', 'desc'], }); }); - const parsedAppPath = parse(`/app/discover#${appPath}`, true, true); - const formattedAppPath = format({ - protocol: parsedAppPath.protocol, - host: parsedAppPath.host, - pathname: parsedAppPath.pathname, - query: parsedAppPath.query, - hash: parsedAppPath.hash, - }); + const parsedAppPath = new URL(`/app/discover#${appPath}`, 'http://localhost'); + const formattedAppPath = `${parsedAppPath.pathname}${parsedAppPath.search}${parsedAppPath.hash}`; // replace the URI encoded version of the tag with the unescaped version // so it can be found with String.replace, regexp, etc. diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts index fdf7651e822cc..da395fe375037 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts @@ -1675,7 +1675,7 @@ describe('getOAuthAccessToken()', () => { }, }, }) - ).rejects.toMatchInlineSnapshot(`[Error: Token URL must contain hostname]`); + ).rejects.toMatchInlineSnapshot(`[TypeError: Invalid URL: /path/to/myfile]`); expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'update' }); }); diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts index 6138c0a491ffb..9f66782e57f36 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts @@ -6,7 +6,6 @@ */ import Boom from '@hapi/boom'; -import url from 'url'; import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { i18n } from '@kbn/i18n'; @@ -369,11 +368,7 @@ export class ActionsClient { } // Verify that token url contains a hostname and uses https - const parsedUrl = url.parse( - options.tokenUrl, - false /* parseQueryString */, - true /* slashesDenoteHost */ - ); + const parsedUrl = new URL(options.tokenUrl); if (!parsedUrl.hostname) { throw Boom.badRequest(`Token URL must contain hostname`); diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.ts index f1f5a6a3c70f3..ed57f1dc9da9c 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_config.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_config.ts @@ -6,10 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { tryCatch, map, mapNullable, getOrElse } from 'fp-ts/Option'; -import url from 'url'; import { curry } from 'lodash'; -import { pipe } from 'fp-ts/pipeable'; import { getSSLSettingsFromConfig, @@ -106,12 +103,12 @@ function isAllowed({ allowedHosts }: ActionsConfig, hostname: string | null): bo } function isHostnameAllowedInUri(config: ActionsConfig, uri: string): boolean { - return pipe( - tryCatch(() => url.parse(uri, false /* parseQueryString */, true /* slashesDenoteHost */)), - map((parsedUrl) => parsedUrl.hostname), - mapNullable((hostname) => isAllowed(config, hostname)), - getOrElse(() => false) - ); + try { + const parsedUrl = new URL(uri.startsWith('//') ? `http:${uri}` : uri); + return isAllowed(config, parsedUrl.hostname || null); + } catch { + return false; + } } function isActionTypeEnabledInConfig( diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts index 972c076a899ea..262ed11dc0020 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/plugins/utils/sourcing/save_uploaded_file.test.ts @@ -72,11 +72,15 @@ describe('saveUploadedFile', () => { it('deletes the temp file when the pipeline fails', async () => { const writeStream = new PassThrough(); - writeStream.destroy(new Error('write error')); mockCreateWriteStream.mockReturnValue(writeStream); const input = Readable.from(Buffer.from('zip content')); + // Destroy the write stream on next tick so pipeline has time to attach error handlers + process.nextTick(() => { + writeStream.destroy(new Error('write error')); + }); + await expect(saveUploadedFile(input)).rejects.toThrow(); expect(mockDeleteFile).toHaveBeenCalledWith('tmp/test-uuid-1234.zip', { diff --git a/x-pack/platform/plugins/shared/inference/scripts/util/cli_options.ts b/x-pack/platform/plugins/shared/inference/scripts/util/cli_options.ts index e8f00cedb9882..dfb9af4e18b46 100644 --- a/x-pack/platform/plugins/shared/inference/scripts/util/cli_options.ts +++ b/x-pack/platform/plugins/shared/inference/scripts/util/cli_options.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { format, parse } from 'url'; import { readKibanaConfig } from './read_kibana_config'; const config = readKibanaConfig(); @@ -19,14 +18,18 @@ export const elasticsearchOption = { alias: 'es', describe: 'Where Elasticsearch is running', string: true as const, - default: format({ - ...parse( + default: (() => { + const elasticsearchUrl = new URL( Array.isArray(config['elasticsearch.hosts']) ? config['elasticsearch.hosts'][0] : config['elasticsearch.hosts'] - ), - auth: `${config['elasticsearch.username']}:${config['elasticsearch.password']}`, - }), + ); + + elasticsearchUrl.username = config['elasticsearch.username']; + elasticsearchUrl.password = config['elasticsearch.password']; + + return elasticsearchUrl.toString(); + })(), }; export const connectorIdOption = { diff --git a/x-pack/platform/plugins/shared/inference/scripts/util/get_service_urls.ts b/x-pack/platform/plugins/shared/inference/scripts/util/get_service_urls.ts index 09c7c18be691c..eb463c59cfc99 100644 --- a/x-pack/platform/plugins/shared/inference/scripts/util/get_service_urls.ts +++ b/x-pack/platform/plugins/shared/inference/scripts/util/get_service_urls.ts @@ -6,24 +6,59 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import { omit } from 'lodash'; -import type { Url } from 'url'; -import { format, parse } from 'url'; -async function discoverAuth(parsedTarget: Url, log: ToolingLog) { +const getAuth = (url: URL): string | undefined => { + if (!url.username && !url.password) { + return undefined; + } + + return `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; +}; + +const setAuth = (url: URL, auth?: string): URL => { + const nextUrl = new URL(url.toString()); + + if (!auth) { + return nextUrl; + } + + const separatorIndex = auth.indexOf(':'); + nextUrl.username = separatorIndex >= 0 ? auth.slice(0, separatorIndex) : auth; + nextUrl.password = separatorIndex >= 0 ? auth.slice(separatorIndex + 1) : ''; + + return nextUrl; +}; + +const stripAuth = (url: URL): URL => { + const nextUrl = new URL(url.toString()); + nextUrl.username = ''; + nextUrl.password = ''; + return nextUrl; +}; + +const getAuthHeaders = (url: URL): Record => { + const auth = getAuth(url); + + if (!auth) { + return {}; + } + + return { Authorization: `Basic ${Buffer.from(auth).toString('base64')}` }; +}; + +async function discoverAuth(parsedTarget: URL, log: ToolingLog) { const possibleCredentials = [`admin:changeme`, `elastic:changeme`]; for (const auth of possibleCredentials) { - const url = format({ - ...parsedTarget, - auth, - }); + const url = setAuth(parsedTarget, auth); let status: number; try { - log.debug(`Fetching ${url}`); - const response = await fetch(url); + log.debug(`Fetching ${stripAuth(url)}`); + const response = await fetch(stripAuth(url).toString(), { + headers: getAuthHeaders(url), + }); status = response.status; } catch (err) { - log.debug(`${url} resulted in ${err.message}`); + log.debug(`${stripAuth(url)} resulted in ${err.message}`); status = 0; } @@ -32,27 +67,22 @@ async function discoverAuth(parsedTarget: Url, log: ToolingLog) { } } - throw new Error(`Failed to authenticate user for ${format(parsedTarget)}`); + throw new Error(`Failed to authenticate user for ${stripAuth(parsedTarget)}`); } async function getKibanaUrl({ kibana, log }: { kibana: string; log: ToolingLog }) { try { const isCI = process.env.CI?.toLowerCase() === 'true'; - const parsedKibanaUrl = parse(kibana); - - const kibanaUrlWithoutAuth = format(omit(parsedKibanaUrl, 'auth')); + const parsedKibanaUrl = new URL(kibana); + const kibanaUrlWithoutAuth = stripAuth(parsedKibanaUrl); log.debug(`Checking Kibana URL ${kibanaUrlWithoutAuth} for a redirect`); let unredirectedResponse; try { - unredirectedResponse = await fetch(kibanaUrlWithoutAuth, { - headers: { - ...(parsedKibanaUrl.auth - ? { Authorization: `Basic ${Buffer.from(parsedKibanaUrl.auth).toString('base64')}` } - : {}), - }, + unredirectedResponse = await fetch(kibanaUrlWithoutAuth.toString(), { + headers: getAuthHeaders(parsedKibanaUrl), method: 'HEAD', redirect: 'manual', }); @@ -62,38 +92,24 @@ async function getKibanaUrl({ kibana, log }: { kibana: string; log: ToolingLog } log.debug('Unredirected response', unredirectedResponse.headers.get('location')); - const discoveredKibanaUrl = + const discoveredKibanaUrl = new URL( unredirectedResponse.headers .get('location') ?.replace('/spaces/enter', '') - ?.replace('spaces/space_selector', '') || kibanaUrlWithoutAuth; + ?.replace('spaces/space_selector', '') || kibanaUrlWithoutAuth.toString(), + kibanaUrlWithoutAuth + ); log.debug(`Discovered Kibana URL at ${discoveredKibanaUrl}`); - const parsedTarget = parse(kibana); - - const parsedDiscoveredUrl = parse(discoveredKibanaUrl); - - const discoveredKibanaUrlWithAuth = format({ - ...parsedDiscoveredUrl, - auth: parsedTarget.auth, - }); - - // Strip credentials from URL for fetch (Node.js fetch doesn't support credentials in URLs) - const discoveredKibanaUrlWithoutAuth = format({ - ...parsedDiscoveredUrl, - auth: undefined, - }); + const discoveredKibanaUrlWithAuth = setAuth(discoveredKibanaUrl, getAuth(parsedKibanaUrl)); + const discoveredKibanaUrlWithoutAuth = stripAuth(discoveredKibanaUrlWithAuth); let redirectedResponse; try { - redirectedResponse = await fetch(discoveredKibanaUrlWithoutAuth, { + redirectedResponse = await fetch(discoveredKibanaUrlWithoutAuth.toString(), { method: 'HEAD', - headers: { - ...(parsedTarget.auth - ? { Authorization: `Basic ${Buffer.from(parsedTarget.auth).toString('base64')}` } - : {}), - }, + headers: getAuthHeaders(discoveredKibanaUrlWithAuth), }); } catch (fetchError: any) { throw fetchError; @@ -111,10 +127,9 @@ async function getKibanaUrl({ kibana, log }: { kibana: string; log: ToolingLog } }` ); - return discoveredKibanaUrlWithAuth.replace(/\/$/, ''); + return discoveredKibanaUrlWithAuth.toString().replace(/\/$/, ''); } catch (error: any) { - const parsedKibanaUrl = parse(kibana); - const kibanaUrlWithoutAuth = format(omit(parsedKibanaUrl, 'auth')); + const kibanaUrlWithoutAuth = stripAuth(new URL(kibana)); const errorCode = error?.code || error?.cause?.code; const isConnectionError = errorCode === 'ECONNREFUSED' || @@ -150,27 +165,18 @@ export async function getServiceUrls({ elasticsearch = 'http://127.0.0.1:9200'; } - const parsedTarget = parse(elasticsearch); - - let auth = parsedTarget.auth; + const parsedTarget = new URL(elasticsearch); + let auth = getAuth(parsedTarget); - if (!parsedTarget.auth) { + if (!auth) { auth = await discoverAuth(parsedTarget, log); } - const formattedEsUrl = format({ - ...parsedTarget, - auth, - }); + const formattedEsUrl = setAuth(parsedTarget, auth).toString(); const suspectedKibanaUrl = kibana || elasticsearch.replace('.es', '.kb'); - - const parsedKibanaUrl = parse(suspectedKibanaUrl); - - const kibanaUrlWithAuth = format({ - ...parsedKibanaUrl, - auth: parsedKibanaUrl.auth || auth, - }); + const parsedKibanaUrl = new URL(suspectedKibanaUrl); + const kibanaUrlWithAuth = setAuth(parsedKibanaUrl, getAuth(parsedKibanaUrl) || auth).toString(); const validatedKibanaUrl = await getKibanaUrl({ kibana: kibanaUrlWithAuth, log }); diff --git a/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts b/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts index 30b411137752d..0e284bac5d491 100644 --- a/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts +++ b/x-pack/platform/plugins/shared/inference/scripts/util/kibana_client.ts @@ -12,7 +12,6 @@ import type { IncomingMessage } from 'http'; import { omit, pick } from 'lodash'; import { from, map, switchMap, throwError } from 'rxjs'; import type { UrlObject } from 'url'; -import { format, parse } from 'url'; import { inspect } from 'util'; import { isReadable } from 'stream'; import type { @@ -58,21 +57,33 @@ export class KibanaClient { } private getUrl(props: { query?: UrlObject['query']; pathname: string; ignoreSpaceId?: boolean }) { - const parsed = parse(this.url); + const url = new URL(this.url); + const baseUrl = url.pathname.replaceAll('/', ''); - const baseUrl = parsed.pathname?.replaceAll('/', '') ?? ''; + url.pathname = `/${[ + ...(baseUrl ? [baseUrl] : []), + ...(props.ignoreSpaceId || !this.spaceId ? [] : ['s', this.spaceId]), + props.pathname.startsWith('/') ? props.pathname.substring(1) : props.pathname, + ].join('/')}`; + url.search = ''; - const url = format({ - ...parsed, - pathname: `/${[ - ...(baseUrl ? [baseUrl] : []), - ...(props.ignoreSpaceId || !this.spaceId ? [] : ['s', this.spaceId]), - props.pathname.startsWith('/') ? props.pathname.substring(1) : props.pathname, - ].join('/')}`, - query: props.query, - }); + if (props.query) { + for (const [key, value] of Object.entries(props.query)) { + if (value == null) { + continue; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + url.searchParams.append(key, String(item)); + }); + } else { + url.searchParams.set(key, String(value)); + } + } + } - return url; + return url.toString(); } callKibana( diff --git a/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.js b/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.js index 2cfdefe1fdabf..f99953fad72b6 100644 --- a/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.js +++ b/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.js @@ -7,7 +7,6 @@ import _ from 'lodash'; import { parseXmlString } from '../../../../common/parse_xml_string'; -import { parse, format } from 'url'; export class WmsClient { constructor({ serviceUrl }) { @@ -19,18 +18,13 @@ export class WmsClient { } _createUrl(defaultQueryParams) { - const serviceUrl = parse(this._serviceUrl, true); - const queryParams = { - ...serviceUrl.query, - ...defaultQueryParams, - }; - return format({ - protocol: serviceUrl.protocol, - hostname: serviceUrl.hostname, - port: serviceUrl.port, - pathname: serviceUrl.pathname, - query: queryParams, + const serviceUrl = new URL(this._serviceUrl); + + Object.entries(defaultQueryParams).forEach(([key, value]) => { + serviceUrl.searchParams.set(key, value); }); + + return serviceUrl.toString(); } getUrlTemplate(layers, styles) { @@ -55,24 +49,12 @@ export class WmsClient { * (ex. service must be WMS) */ async _fetchCapabilities() { - const getCapabilitiesUrl = parse(this._serviceUrl, true); - const queryParams = { - ...getCapabilitiesUrl.query, - ...{ - version: '1.1.1', - request: 'GetCapabilities', - service: 'WMS', - }, - }; - const resp = await this._fetch( - format({ - protocol: getCapabilitiesUrl.protocol, - hostname: getCapabilitiesUrl.hostname, - port: getCapabilitiesUrl.port, - pathname: getCapabilitiesUrl.pathname, - query: queryParams, - }) - ); + const getCapabilitiesUrl = new URL(this._serviceUrl); + getCapabilitiesUrl.searchParams.set('version', '1.1.1'); + getCapabilitiesUrl.searchParams.set('request', 'GetCapabilities'); + getCapabilitiesUrl.searchParams.set('service', 'WMS'); + + const resp = await this._fetch(getCapabilitiesUrl.toString()); if (resp.status >= 400) { throw new Error(`Unable to access ${this.state.serviceUrl}`); } diff --git a/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.test.js b/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.test.js index 3690def704955..09ed3ee71f7fb 100644 --- a/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.test.js +++ b/x-pack/platform/plugins/shared/maps/public/classes/sources/wms_source/wms_client.test.js @@ -9,7 +9,7 @@ import { WmsClient } from './wms_client'; describe('getCapabilities', () => { it('Should extract flat Layer elements', async () => { - const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + const wmsClient = new WmsClient({ serviceUrl: 'https://elastic.co/wms' }); wmsClient._fetch = () => { return { status: 200, @@ -53,7 +53,7 @@ describe('getCapabilities', () => { // Good example of Layer hierarchy in the wild can be found at // https://idpgis.ncep.noaa.gov/arcgis/services/NWS_Forecasts_Guidance_Warnings/NDFD_temp/MapServer/WMSServer it('Should extract hierarchical Layer elements', async () => { - const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + const wmsClient = new WmsClient({ serviceUrl: 'https://elastic.co/wms' }); wmsClient._fetch = () => { return { status: 200, @@ -112,7 +112,7 @@ describe('getCapabilities', () => { }); it('Should create group from common parts of Layer hierarchy', async () => { - const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + const wmsClient = new WmsClient({ serviceUrl: 'https://elastic.co/wms' }); wmsClient._fetch = () => { return { status: 200, @@ -176,7 +176,7 @@ describe('getCapabilities', () => { }); it('Should ensure no option labels have name collisions', async () => { - const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + const wmsClient = new WmsClient({ serviceUrl: 'https://elastic.co/wms' }); wmsClient._fetch = () => { return { status: 200, @@ -223,7 +223,7 @@ describe('getCapabilities', () => { }); it('Should not create group common hierarchy when there is only a single layer', async () => { - const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + const wmsClient = new WmsClient({ serviceUrl: 'https://elastic.co/wms' }); wmsClient._fetch = () => { return { status: 200, diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/custom_urls/custom_url_editor/utils.ts b/x-pack/platform/plugins/shared/ml/public/application/components/custom_urls/custom_url_editor/utils.ts index db2ac45e76eab..11baeb33092c4 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/custom_urls/custom_url_editor/utils.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/components/custom_urls/custom_url_editor/utils.ts @@ -10,7 +10,6 @@ import moment, { type Moment } from 'moment'; import { cloneDeep } from 'lodash'; import type { SerializableRecord } from '@kbn/utility-types'; import rison from '@kbn/rison'; -import url from 'url'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { cleanEmptyKeys } from '@kbn/dashboard-plugin/public'; @@ -324,7 +323,7 @@ async function buildDashboardUrlFromSettings( const urlToAdd: MlUrlConfig = { url_name: settings.label, - url_value: decodeURIComponent(`dashboards${url.parse(resultPath).hash}`), + url_value: decodeURIComponent(`dashboards${new URL(resultPath, 'http://localhost').hash}`), time_range: TIME_RANGE_TYPE.AUTO as string, }; diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts index 6dd28499d39d3..76068e1694f56 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts @@ -13,7 +13,6 @@ import type { CDPSession } from 'puppeteer'; import { truncate } from 'lodash'; import type { ElementHandle, EvaluateFunc, HTTPResponse, Page } from 'puppeteer'; import { Subject } from 'rxjs'; -import { parse as parseUrl } from 'url'; import { getDisallowedOutgoingUrlError } from '.'; import type { Layout } from '../../layouts'; import { getPrintLayoutSelectors } from '../../layouts/print_layout'; @@ -462,22 +461,18 @@ export class HeadlessChromiumDriver { hostname: sourceHostname, protocol: sourceProtocol, port: sourcePort, - } = parseUrl(sourceUrl); + } = new URL(sourceUrl); const { hostname: targetHostname, protocol: targetProtocol, port: targetPort, pathname: targetPathname, - } = parseUrl(targetUrl); - - if (targetPathname === null) { - throw new Error(`URL missing pathname: ${targetUrl}`); - } + } = new URL(targetUrl); // `port` is null in URLs that don't explicitly state it, // however we can derive the port from the protocol (http/https) // IE: https://feeds.elastic.co/kibana/v8.0.0.json - const derivedPort = (protocol: string | null, port: string | null, url: string) => { + const derivedPort = (protocol: string, port: string, url: string) => { if (port) { return port; } diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/network_policy.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/network_policy.ts index 4d47b01889924..a66765420447e 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/network_policy.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/network_policy.ts @@ -6,7 +6,6 @@ */ import { every } from 'lodash'; -import { parse } from 'url'; interface NetworkPolicyRule { allow: boolean; @@ -27,12 +26,17 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { }; export function allowRequest(url: string, rules: NetworkPolicyRule[]): boolean { - const parsed = parse(url); - if (!rules.length) { return true; } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + // Accumulator has three potential values here: // True => allow request, don't check other rules // False => reject request, don't check other rules @@ -42,7 +46,7 @@ export function allowRequest(url: string, rules: NetworkPolicyRule[]): boolean { return result; } - const hostMatch = rule.host ? isHostMatch(parsed.host || '', rule.host) : true; + const hostMatch = rule.host ? isHostMatch(parsed.host, rule.host) : true; const protocolMatch = rule.protocol ? parsed.protocol === rule.protocol : true; diff --git a/x-pack/platform/plugins/shared/spaces/server/lib/utils/url.ts b/x-pack/platform/plugins/shared/spaces/server/lib/utils/url.ts index 2e434fe0de6f9..87fb7b4b30802 100644 --- a/x-pack/platform/plugins/shared/spaces/server/lib/utils/url.ts +++ b/x-pack/platform/plugins/shared/spaces/server/lib/utils/url.ts @@ -11,7 +11,104 @@ import type { ParsedQuery } from 'query-string'; import type { UrlObject } from 'url'; -import { format as formatUrl, parse as parseUrl } from 'url'; +import { format as formatUrl } from 'url'; + +const parseAbsoluteUrl = (url: string): URL | undefined => { + try { + return new URL(url); + } catch { + return undefined; + } +}; + +const parseProtocolRelativeUrl = (url: string): URL | undefined => { + if (!url.startsWith('//')) { + return undefined; + } + + try { + return new URL(`http:${url}`); + } catch { + return undefined; + } +}; + +const splitRelativeUrl = (url: string) => { + const hashIndex = url.indexOf('#'); + const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex); + const hash = hashIndex === -1 ? null : url.slice(hashIndex) || null; + const searchIndex = beforeHash.indexOf('?'); + const pathname = + searchIndex === -1 ? beforeHash || null : beforeHash.slice(0, searchIndex) || null; + const search = searchIndex === -1 ? null : beforeHash.slice(searchIndex) || null; + + return { hash, pathname, search }; +}; + +const parseQuery = (searchParams: URLSearchParams): ParsedQuery => { + const query: ParsedQuery = {}; + + for (const [key, value] of searchParams.entries()) { + const existingValue = query[key]; + + if (existingValue === undefined) { + query[key] = value; + continue; + } + + query[key] = Array.isArray(existingValue) + ? [...existingValue.filter((item): item is string => item !== null), value] + : existingValue === null + ? value + : [existingValue, value]; + } + + return query; +}; + +const formatAuth = (username: string, password: string): string | null => { + if (!username && !password) { + return null; + } + + if (!password) { + return decodeURIComponent(username); + } + + return `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; +}; + +const parseMeaningfulUrlParts = (url: string): URLMeaningfulParts => { + const absoluteUrl = parseAbsoluteUrl(url); + const protocolRelativeUrl = absoluteUrl ? undefined : parseProtocolRelativeUrl(url); + const parsedUrl = absoluteUrl ?? protocolRelativeUrl; + + if (!parsedUrl) { + const { hash, pathname, search } = splitRelativeUrl(url); + + return { + auth: null, + hash, + hostname: null, + pathname, + port: null, + protocol: null, + query: parseQuery(new URLSearchParams(search?.slice(1) ?? '')), + slashes: null, + }; + } + + return { + auth: formatAuth(parsedUrl.username, parsedUrl.password), + hash: parsedUrl.hash || null, + hostname: parsedUrl.hostname || null, + pathname: parsedUrl.pathname || null, + port: parsedUrl.port || null, + protocol: absoluteUrl ? parsedUrl.protocol || null : null, + query: parseQuery(parsedUrl.searchParams), + slashes: true, + }; +}; export interface URLMeaningfulParts { auth: string | null; @@ -57,7 +154,15 @@ export function modifyUrl( url: string, urlModifier: (urlParts: URLMeaningfulParts) => Partial | undefined ) { - const parsed = parseUrl(url, true) as URLMeaningfulParts; + if (typeof url !== 'string') { + throw new TypeError('Expected URL to be a string'); + } + + if (typeof urlModifier !== 'function') { + throw new TypeError('Expected urlModifier to be a function'); + } + + const parsed = parseMeaningfulUrlParts(url); // Copy over the most specific version of each property. By default, the parsed url includes several // conflicting properties (like path and pathname + search, or search and query) and keeping track diff --git a/x-pack/platform/test/functional/apps/maps/group5/saved_object_management.js b/x-pack/platform/test/functional/apps/maps/group5/saved_object_management.js index 7439bfb75c702..d7065e14e3c41 100644 --- a/x-pack/platform/test/functional/apps/maps/group5/saved_object_management.js +++ b/x-pack/platform/test/functional/apps/maps/group5/saved_object_management.js @@ -84,9 +84,9 @@ export default function ({ getPageObjects, getService }) { it('should update app state with query stored with map', async () => { const currentUrl = await browser.getCurrentUrl(); - const appState = currentUrl.substring(currentUrl.indexOf('_a=')); + const appState = decodeURIComponent(currentUrl.substring(currentUrl.indexOf('_a='))); expect(appState).to.equal( - '_a=(filters:!(),query:(language:kuery,query:%27machine.os.raw%20:%20%22ios%22%27))' + `_a=(filters:!(),query:(language:kuery,query:'machine.os.raw : "ios"'))` ); }); @@ -132,9 +132,9 @@ export default function ({ getPageObjects, getService }) { it('should update app state with query stored with map', async () => { const currentUrl = await browser.getCurrentUrl(); - const appState = currentUrl.substring(currentUrl.indexOf('_a=')); + const appState = decodeURIComponent(currentUrl.substring(currentUrl.indexOf('_a='))); expect(appState).to.equal( - '_a=(filters:!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:c698b940-e149-11e8-a35a-370a8516603a,key:machine.os.raw,negate:!f,params:(query:ios),type:phrase),query:(match_phrase:(machine.os.raw:(query:ios))))),query:(language:kuery,query:%27%27))' + `_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:c698b940-e149-11e8-a35a-370a8516603a,key:machine.os.raw,negate:!f,params:(query:ios),type:phrase),query:(match_phrase:(machine.os.raw:(query:ios))))),query:(language:kuery,query:''))` ); }); diff --git a/x-pack/platform/test/functional/apps/security/role_mappings.ts b/x-pack/platform/test/functional/apps/security/role_mappings.ts index 3df0a6366976d..b9d6fae5f3be5 100644 --- a/x-pack/platform/test/functional/apps/security/role_mappings.ts +++ b/x-pack/platform/test/functional/apps/security/role_mappings.ts @@ -6,7 +6,6 @@ */ import expect from '@kbn/expect'; -import { parse } from 'url'; import type { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { @@ -96,7 +95,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.existOrFail('errorLoadingRoleMappingEditorToast'); - const url = parse(await browser.getCurrentUrl()); + const url = new URL(await browser.getCurrentUrl()); expect(url.pathname).to.eql('/app/management/security/role_mappings/'); }); diff --git a/x-pack/platform/test/functional/apps/security/security.ts b/x-pack/platform/test/functional/apps/security/security.ts index 6ed4b27e77e0e..2cb4fab0c81bb 100644 --- a/x-pack/platform/test/functional/apps/security/security.ts +++ b/x-pack/platform/test/functional/apps/security/security.ts @@ -6,7 +6,6 @@ */ import expect from '@kbn/expect'; -import { parse } from 'url'; import type { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -80,7 +79,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.logout(); const currentUrl = await browser.getCurrentUrl(); - const url = parse(currentUrl); + const url = new URL(currentUrl); expect(url.pathname).to.eql('/login'); }); }); diff --git a/x-pack/platform/test/functional_cloud/tests/onboarding.ts b/x-pack/platform/test/functional_cloud/tests/onboarding.ts index 3e82bff0abdf5..5411c110ab80a 100644 --- a/x-pack/platform/test/functional_cloud/tests/onboarding.ts +++ b/x-pack/platform/test/functional_cloud/tests/onboarding.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { parse } from 'url'; - import expect from '@kbn/expect'; import { MAIN_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; @@ -53,7 +51,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/observability/landing'); }); @@ -67,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/elasticsearch/getting_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -94,7 +92,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -130,7 +128,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -199,7 +197,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -236,7 +234,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -306,7 +304,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -342,7 +340,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); @@ -411,7 +409,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000); // We need to make sure that both path and hash are respected. - const currentURL = parse(await browser.getCurrentUrl()); + const currentURL = new URL(await browser.getCurrentUrl()); expect(currentURL.pathname).to.eql('/app/security/get_started'); expect(currentURL.hash).to.eql('#some=hash-value'); diff --git a/x-pack/platform/test/functional_cors/plugins/kibana_cors_test/server/plugin.ts b/x-pack/platform/test/functional_cors/plugins/kibana_cors_test/server/plugin.ts index c752f26c80cdb..e9742eb3d14a1 100644 --- a/x-pack/platform/test/functional_cors/plugins/kibana_cors_test/server/plugin.ts +++ b/x-pack/platform/test/functional_cors/plugins/kibana_cors_test/server/plugin.ts @@ -15,7 +15,7 @@ import type { ConfigSchema } from './config'; const apiToken = Buffer.from(kbnTestConfig.getUrlParts().auth!).toString('base64'); function renderBody(kibanaUrl: string) { - const url = Url.resolve(kibanaUrl, '/cors-test'); + const url = new URL('/cors-test', kibanaUrl).toString(); return ` @@ -23,7 +23,7 @@ function renderBody(kibanaUrl: string) { Request to CORS Kibana -