diff --git a/x-pack/platform/plugins/private/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts b/x-pack/platform/plugins/private/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts index ae5183e280f56..5921b93f44ecc 100644 --- a/x-pack/platform/plugins/private/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts +++ b/x-pack/platform/plugins/private/remote_clusters/__jest__/client_integration/add/remote_clusters_add.test.ts @@ -151,13 +151,13 @@ describe('Create Remote cluster', () => { }); describe('seeds', () => { - test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => { + test('should only allow hostname, ipv4 and ipv6 format in the node "host" part', async () => { await actions.formStep.button.click(); // display form errors const expectInvalidChar = (char: string) => { actions.formStep.seedsInput.setValue(`192.16${char}:3000`); expect(actions.getErrorMessages()).toContain( - `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.` + `Seed node must use host:port format. Example: 127.0.0.1:9400, [::1]:9400 or localhost:9400. Hosts can only consist of letters, numbers, and dashes.` ); }; @@ -289,7 +289,7 @@ describe('Create Remote cluster', () => { const expectInvalidChar = (char: string) => { actions.formStep.proxyAddressInput.setValue(`192.16${char}:3000`); expect(actions.getErrorMessages()).toContain( - 'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.' + 'Address must use host:port format. Example: 127.0.0.1:9400, [::1]:9400 or localhost:9400. Hosts can only consist of letters, numbers, and dashes.' ); }; diff --git a/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.test.ts b/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.test.ts index 058411173bbc1..254be1925f80f 100644 --- a/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.test.ts +++ b/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.test.ts @@ -135,41 +135,52 @@ describe('cluster_serialization', () => { }); }); - it('should deserialize a cluster that contains a deprecated proxy address and is in cloud', () => { - expect( - deserializeCluster( - 'test_cluster', - { - seeds: ['localhost:9300'], - connected: true, - num_nodes_connected: 1, - max_connections_per_cluster: 3, - initial_connect_timeout: '30s', - skip_unavailable: false, - transport: { - ping_schedule: '-1', - compress: false, + it.each([ + { title: '(hostname)', address: 'localhost:3000', host: 'localhost' }, + { title: '(IPv4)', address: '123.1.2.142:3000', host: '123.1.2.142' }, + { + title: '(IPv6)', + address: '[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:3000', + host: '[2001:0db8:85a3:0000:0000:8a2e:0370:7334]', + }, + ])( + 'should deserialize a cluster that contains a deprecated proxy address and is in cloud $title', + ({ address, host }) => { + expect( + deserializeCluster( + 'test_cluster', + { + seeds: [address], + connected: true, + num_nodes_connected: 1, + max_connections_per_cluster: 3, + initial_connect_timeout: '30s', + skip_unavailable: false, + transport: { + ping_schedule: '-1', + compress: false, + }, }, - }, - 'localhost:9300', - true - ) - ).toEqual({ - name: 'test_cluster', - proxyAddress: 'localhost:9300', - mode: 'proxy', - hasDeprecatedProxySetting: true, - isConnected: true, - connectedNodesCount: 1, - maxConnectionsPerCluster: 3, - initialConnectTimeout: '30s', - skipUnavailable: false, - transportPingSchedule: '-1', - transportCompress: false, - serverName: 'localhost', - securityModel: SECURITY_MODEL.CERTIFICATE, - }); - }); + address, + true + ) + ).toEqual({ + name: 'test_cluster', + proxyAddress: address, + mode: 'proxy', + hasDeprecatedProxySetting: true, + isConnected: true, + connectedNodesCount: 1, + maxConnectionsPerCluster: 3, + initialConnectTimeout: '30s', + skipUnavailable: false, + transportPingSchedule: '-1', + transportCompress: false, + serverName: host, + securityModel: SECURITY_MODEL.CERTIFICATE, + }); + } + ); it('should deserialize a cluster object with arbitrary missing properties', () => { expect( diff --git a/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.ts b/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.ts index 061ca04352c66..6ff0cc6ab82cb 100644 --- a/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.ts +++ b/x-pack/platform/plugins/private/remote_clusters/common/lib/cluster_serialization.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { extractHostAndPort } from './validate_address'; import { PROXY_MODE, SNIFF_MODE, SECURITY_MODEL } from '../constants'; // Values returned from ES GET /_remote/info @@ -143,19 +144,21 @@ export function deserializeCluster( }; } + const deprecatedProxyHost = deprecatedProxyAddress + ? extractHostAndPort(deprecatedProxyAddress)?.host + : undefined; + // If a user has a remote cluster with the deprecated proxy setting, // we transform the data to support the new implementation and also flag the deprecation - if (deprecatedProxyAddress) { - // Cloud-specific logic: Create default server name, since field doesn't exist in deprecated implementation - const defaultServerName = deprecatedProxyAddress.split(':')[0]; - + if (deprecatedProxyAddress && deprecatedProxyHost) { deserializedClusterObject = { ...deserializedClusterObject, proxyAddress: deprecatedProxyAddress, seeds: undefined, hasDeprecatedProxySetting: true, mode: PROXY_MODE, - serverName: isCloudEnabled ? defaultServerName : undefined, + // Cloud-specific logic: Create default server name, since this field doesn't exist in deprecated implementation + serverName: isCloudEnabled ? deprecatedProxyHost : undefined, }; } diff --git a/x-pack/platform/plugins/private/remote_clusters/common/lib/index.ts b/x-pack/platform/plugins/private/remote_clusters/common/lib/index.ts index eaf55d6b91b5b..82f96851a0439 100644 --- a/x-pack/platform/plugins/private/remote_clusters/common/lib/index.ts +++ b/x-pack/platform/plugins/private/remote_clusters/common/lib/index.ts @@ -12,3 +12,4 @@ export type { ClusterPayloadEs, } from './cluster_serialization'; export { deserializeCluster, serializeCluster } from './cluster_serialization'; +export { extractHostAndPort, isAddressValid, isPortValid } from './validate_address'; diff --git a/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.test.ts b/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.test.ts new file mode 100644 index 0000000000000..769d3b846010d --- /dev/null +++ b/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.test.ts @@ -0,0 +1,284 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isAddressValid, isPortValid, extractHostAndPort } from './validate_address'; + +describe('Validate address', () => { + describe('isAddressValid', () => { + describe('rejects invalid formats', () => { + it('empty or undefined', () => { + expect(isAddressValid('')).toBe(false); + expect(isAddressValid(undefined)).toBe(false); + }); + + it('adjacent periods in hostname', () => { + expect(isAddressValid('a..b')).toBe(false); + }); + + it('underscores in hostname', () => { + expect(isAddressValid('host_name')).toBe(false); + }); + + it('special characters in hostname', () => { + ['/', '\\', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '=', '+', '?'].forEach( + (char) => { + expect(isAddressValid(`host${char}name`)).toBe(false); + } + ); + }); + + it('invalid IPv4 addresses', () => { + expect(isAddressValid('256.1.1.1')).toBe(false); + expect(isAddressValid('1.256.1.1')).toBe(false); + expect(isAddressValid('1.1.256.1')).toBe(false); + expect(isAddressValid('1.1.1.256')).toBe(false); + expect(isAddressValid('1.1.1')).toBe(false); + expect(isAddressValid('1.1.1.1.1')).toBe(false); + }); + + it('invalid IPv6 - double double colon', () => { + expect(isAddressValid('[::1::2]')).toBe(false); + }); + + it('invalid IPv6 - missing closing bracket', () => { + expect(isAddressValid('[2001:db8::1')).toBe(false); + }); + + it('invalid IPv6 - invalid hex characters', () => { + expect(isAddressValid('[2001:db8::xyz]')).toBe(false); + expect(isAddressValid('[gggg::]')).toBe(false); + }); + + it('invalid IPv6 - too many segments', () => { + expect(isAddressValid('[1:2:3:4:5:6:7:8:9]')).toBe(false); + }); + + it('invalid IPv6 - segments too long', () => { + expect(isAddressValid('[12345::]')).toBe(false); + expect(isAddressValid('[::12345]')).toBe(false); + }); + }); + + describe('accepts valid formats', () => { + describe('hostnames', () => { + it('simple hostnames', () => { + expect(isAddressValid('localhost')).toBe(true); + expect(isAddressValid('server')).toBe(true); + expect(isAddressValid('my-server')).toBe(true); + }); + + it('fully qualified domain names', () => { + expect(isAddressValid('example.com')).toBe(true); + expect(isAddressValid('sub.example.com')).toBe(true); + expect(isAddressValid('a.b.c.d.e.f')).toBe(true); + }); + + it('hostnames with numbers', () => { + expect(isAddressValid('server1')).toBe(true); + expect(isAddressValid('123server')).toBe(true); + expect(isAddressValid('server-123')).toBe(true); + }); + + it('hostnames with uppercase', () => { + expect(isAddressValid('SERVER')).toBe(true); + expect(isAddressValid('Server.Example.COM')).toBe(true); + }); + }); + + describe('IPv4 addresses', () => { + it('valid IPv4 addresses', () => { + expect(isAddressValid('192.168.1.1')).toBe(true); + expect(isAddressValid('10.0.0.1')).toBe(true); + expect(isAddressValid('255.255.255.255')).toBe(true); + expect(isAddressValid('0.0.0.0')).toBe(true); + }); + }); + + describe('IPv6 addresses', () => { + it('standard notation', () => { + expect(isAddressValid('[2001:db8::1]')).toBe(true); + expect(isAddressValid('[2001:db8:85a3::8a2e:370:7334]')).toBe(true); + }); + + it('compressed notation', () => { + expect(isAddressValid('[::1]')).toBe(true); + expect(isAddressValid('[::]')).toBe(true); + expect(isAddressValid('[::ffff]')).toBe(true); + expect(isAddressValid('[2001::]')).toBe(true); + expect(isAddressValid('[::2001]')).toBe(true); + }); + + it('link-local with zone identifier', () => { + expect(isAddressValid('[fe80::1%eth0]')).toBe(true); + expect(isAddressValid('[fe80::1%1]')).toBe(true); + expect(isAddressValid('[fe80::1%en0]')).toBe(true); + }); + + it('IPv4-mapped IPv6', () => { + expect(isAddressValid('[::ffff:192.168.1.1]')).toBe(true); + expect(isAddressValid('[::ffff:10.0.0.1]')).toBe(true); + // ipaddr.js does not support this well-known format for now + // in case we want to add support in the future + // see https://datatracker.ietf.org/doc/html/rfc6052#section-2.1 + // + // expect(isAddressValid('[64:ff9b::192.0.2.1]')).toBe(true); + }); + + it('full expanded form', () => { + expect(isAddressValid('[2001:0db8:0000:0000:0000:0000:0000:0001]')).toBe(true); + expect(isAddressValid('[2001:0db8:0000:0042:0000:8a2e:0370:7334]')).toBe(true); + }); + + it('IPv6 without brackets (not recommended but valid)', () => { + expect(isAddressValid('2001:db8::1')).toBe(true); + expect(isAddressValid('::1')).toBe(true); + expect(isAddressValid('::')).toBe(true); + }); + }); + }); + }); + + describe('isPortValid', () => { + describe('rejects invalid formats', () => { + it('missing port', () => { + expect(isPortValid('hostname')).toBe(false); + expect(isPortValid('192.168.1.1')).toBe(false); + expect(isPortValid('[2001:db8::1]')).toBe(false); + }); + + it('empty port', () => { + expect(isPortValid('hostname:')).toBe(false); + expect(isPortValid('[2001:db8::1]:')).toBe(false); + }); + + it('non-numeric port', () => { + expect(isPortValid('hostname:abc')).toBe(false); + expect(isPortValid('hostname:80a')).toBe(false); + expect(isPortValid('hostname:8 0')).toBe(false); + expect(isPortValid('[2001:db8::1]:abc')).toBe(false); + }); + + it('multiple ports', () => { + expect(isPortValid('host:80:443')).toBe(false); + }); + }); + + describe('accepts valid formats', () => { + describe('hostname/IPv4 with port', () => { + it('hostname with port', () => { + expect(isPortValid('localhost:9200')).toBe(true); + expect(isPortValid('example.com:443')).toBe(true); + }); + + it('IPv4 with port', () => { + expect(isPortValid('192.168.1.1:9300')).toBe(true); + expect(isPortValid('10.0.0.1:80')).toBe(true); + }); + + it('various port numbers', () => { + expect(isPortValid('host:1')).toBe(true); + expect(isPortValid('host:80')).toBe(true); + expect(isPortValid('host:443')).toBe(true); + expect(isPortValid('host:9200')).toBe(true); + expect(isPortValid('host:65535')).toBe(true); + expect(isPortValid('host:100000000')).toBe(true); // Beyond standard range + }); + }); + + describe('IPv6 with port', () => { + it('standard notation with port', () => { + expect(isPortValid('[2001:db8::1]:9300')).toBe(true); + expect(isPortValid('[2001:db8:85a3::8a2e:370:7334]:443')).toBe(true); + }); + + it('compressed notation with port', () => { + expect(isPortValid('[::1]:9300')).toBe(true); + expect(isPortValid('[::]:9300')).toBe(true); + }); + + it('link-local with zone and port', () => { + expect(isPortValid('[fe80::1%eth0]:9300')).toBe(true); + expect(isPortValid('[fe80::1%1]:80')).toBe(true); + }); + + it('IPv4-mapped with port', () => { + expect(isPortValid('[::ffff:192.168.1.1]:9300')).toBe(true); + expect(isPortValid('[64:ff9b::192.0.2.1]:443')).toBe(true); + }); + + it('full expanded form with port', () => { + expect(isPortValid('[2001:0db8:0000:0000:0000:0000:0000:0001]:9300')).toBe(true); + }); + }); + }); + }); + + describe('extractHostAndPort', () => { + describe('IPv4 and hostname parsing', () => { + it('extracts hostname without port', () => { + const result = extractHostAndPort('hostname'); + expect(result).toEqual({ host: 'hostname' }); + }); + + it('extracts IPv4 without port', () => { + const result = extractHostAndPort('1.1.1.1'); + expect(result).toEqual({ host: '1.1.1.1' }); + }); + + it('extracts IPv4 with port', () => { + const result = extractHostAndPort('2.2.2.2:9200'); + expect(result).toEqual({ host: '2.2.2.2', port: '9200' }); + }); + + it('extracts hostname with port', () => { + const result = extractHostAndPort('hostname:9200'); + expect(result).toEqual({ host: 'hostname', port: '9200' }); + }); + + it('extracts domain with port', () => { + const result = extractHostAndPort('example.com:443'); + expect(result).toEqual({ host: 'example.com', port: '443' }); + }); + }); + + describe('IPv6 parsing', () => { + it('extracts IPv6 host without port', () => { + const result = extractHostAndPort('[2001:db8::1]'); + expect(result).toEqual({ host: '[2001:db8::1]' }); + }); + + it('extracts naked IPv6 host without port', () => { + const result = extractHostAndPort('2001:db8::1'); + expect(result).toEqual({ host: '2001:db8::1' }); + }); + + it('extracts IPv6 host with port', () => { + const result = extractHostAndPort('[2001:db8::1]:9300'); + expect(result).toEqual({ host: '[2001:db8::1]', port: '9300' }); + }); + + it('extracts IPv6 loopback with port', () => { + const result = extractHostAndPort('[::1]:9300'); + expect(result).toEqual({ host: '[::1]', port: '9300' }); + }); + + it('extracts IPv6 wildcard with port', () => { + const result = extractHostAndPort('[::]:9300'); + expect(result).toEqual({ host: '[::]', port: '9300' }); + }); + + it('extracts IPv6 with zone identifier and port', () => { + const result = extractHostAndPort('[fe80::1%eth0]:9300'); + expect(result).toEqual({ host: '[fe80::1%eth0]', port: '9300' }); + }); + + it('extracts IPv4-mapped IPv6 host with port', () => { + const result = extractHostAndPort('[::ffff:192.168.1.1]:9300'); + expect(result).toEqual({ host: '[::ffff:192.168.1.1]', port: '9300' }); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.ts b/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.ts new file mode 100644 index 0000000000000..19baa9ca31723 --- /dev/null +++ b/x-pack/platform/plugins/private/remote_clusters/common/lib/validate_address.ts @@ -0,0 +1,159 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IPv4, IPv6 } from 'ipaddr.js'; + +const HOSTNAME_SEGMENT_RE = /^[a-z0-9\-]+$/i; + +function isValidHostnameSegment(segment: string): boolean { + return HOSTNAME_SEGMENT_RE.test(segment); +} + +function isValidHostname(hostname: string): boolean { + return hostname.split('.').every(isValidHostnameSegment); +} + +/** + * Helps determine if host is numeric IPv4-like + * but not necessarily valid IPv4 so we can validate in place + * and exit without going through ipv6 or hostname checks + * + * ex. "1.1", "1.2.3" + */ +function looksLikeIPv4(host: string): boolean { + // If it contains dots and all segments are numeric, it's an IPv4 attempt + if (!host.includes('.')) return false; + + const segments = host.split('.'); + return segments.every((seg) => /^\d+$/.test(seg)); +} + +const IP4_RE = /^\d+\.\d+\.\d+\.\d+$/; + +function isValidIPv4(host: string): boolean { + // ipaddr.js is permissive with ip4 addresses, allowing things like "1", "1.2", "1.2.3" + // We want to enforce the full four-octet format, so we add this stricter regex check first + return IP4_RE.test(host) && IPv4.isValid(host); +} + +/** + * Parses a seed node string into address and port components + * Handles IPv4, IPv6 (bracketed and unbracketed), and hostnames + */ +export function extractHostAndPort(seedNode: string): { host?: string; port?: string } | undefined { + // Check for bracketed IPv6 + if (seedNode.startsWith('[')) { + const bracketEnd = seedNode.indexOf(']'); + if (bracketEnd === -1) { + return undefined; + } + + // include brackets in host + const host = seedNode.substring(0, bracketEnd + 1); + const remainder = seedNode.substring(bracketEnd + 1); + + if (remainder.startsWith(':')) { + return { host, port: remainder.substring(1) }; + } + + return { host }; + } + + // Check if it's a valid IPv6 without brackets + // If so, return as address only (no port) + // because IPv6 with port must be bracketed + if (IPv6.isValid(seedNode)) { + return { host: seedNode }; + } + + // For IPv4 or hostname, only allow single colon for port + const colonCount = (seedNode.match(/:/g) || []).length; + + // Multiple colons in non-IPv6 context means invalid format + if (colonCount > 1) { + // It's not a valid IPv6 (we checked above), so multiple colons are invalid + return undefined; + } + + // Safe to split on colon for port + if (colonCount === 1) { + const colonIndex = seedNode.indexOf(':'); + return { + host: seedNode.substring(0, colonIndex), + port: seedNode.substring(colonIndex + 1), + }; + } + + // No port, just return address + return { host: seedNode }; +} + +/** + * Validates the host part of a seed node string. + * Seed node can be in variations of: + * + * - hostname + * - ipv4 + * - ipv6 (bracketed and unbracketed) + * - hostname:port + * - ipv4:port + * - [ipv6]:port + * ... etc + */ +export function isAddressValid(seedNode?: string): boolean { + if (!seedNode) return false; + + // Handle bracketed addresses (IPv6) + if (seedNode.startsWith('[')) { + const bracketEnd = seedNode.indexOf(']'); + if (bracketEnd === -1) return false; + + const unbrackedIPv6Host = seedNode.substring(1, bracketEnd); + return IPv6.isValid(unbrackedIPv6Host); + } + + const { host } = extractHostAndPort(seedNode) ?? {}; + if (!host) return false; + + if (looksLikeIPv4(host)) { + return isValidIPv4(host); + } + + // At this point we know address is neither bracketed IPv6 nor IPv4-like + // + // But it could still be unbracketed IPv6 which is valid as host alone + // + // ex. "2001:db8::1", "::1", "::" + if (IPv6.isValid(host)) { + return true; + } + + return isValidHostname(host); +} + +/** + * Validates the port part of a seed node string. + * Seed node can be in variations of: + * + * - hostname + * - ipv4 + * - ipv6 (bracketed and unbracketed) + * - hostname:port + * - ipv4:port + * - [ipv6]:port + * ... etc + */ +export function isPortValid(seedNode?: string): boolean { + if (!seedNode) return false; + + const { host, port } = extractHostAndPort(seedNode) ?? {}; + + // Must have both valid address and port + if (!host || !port) return false; + + return /^\d+$/.test(port); +} diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/remote_cluster_form.tsx b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/remote_cluster_form.tsx index 584d77e42ac00..ad915b188c78b 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/remote_cluster_form.tsx +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/remote_cluster_form.tsx @@ -29,6 +29,7 @@ import { } from '@elastic/eui'; import type { ReactNode } from 'react-markdown'; import type { Cluster, ClusterPayload } from '../../../../../../common/lib'; +import { extractHostAndPort } from '../../../../../../common/lib'; import { SNIFF_MODE, PROXY_MODE } from '../../../../../../common/constants'; import { AppContext } from '../../../../app_context'; import { skippingDisconnectedClustersUrl } from '../../../../services/documentation'; @@ -162,9 +163,12 @@ export const RemoteClusterForm: React.FC = ({ // If we switch off the advanced options, revert the server name to // the host name from the proxy address if (cloudAdvancedOptionsEnabled === false) { + const serverName = fields.proxyAddress + ? extractHostAndPort(fields.proxyAddress)?.host + : undefined; changedFields = { ...changedFields, - serverName: fields.proxyAddress?.split(':')[0], + serverName, proxySocketConnections: defaultClusterValues.proxySocketConnections, }; } diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/__snapshots__/validate_proxy.test.ts.snap b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/__snapshots__/validate_proxy.test.ts.snap index b8d68971de184..07091bc467be7 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/__snapshots__/validate_proxy.test.ts.snap +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/__snapshots__/validate_proxy.test.ts.snap @@ -1,8 +1,36 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`validateProxy IPv4 addresses rejects invalid IPv4 address 1`] = ` + +`; + +exports[`validateProxy IPv4 addresses rejects valid IPv4 address without port 1`] = ` + +`; + +exports[`validateProxy IPv6 addresses rejects IPv6 address without brackets and port 1`] = ` + +`; + +exports[`validateProxy IPv6 addresses rejects invalid IPv6 address 1`] = ` + +`; + exports[`validateProxy rejects proxy address when the address is invalid 1`] = ` `; diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.test.ts b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.test.ts deleted file mode 100644 index 2c12b9de28ead..0000000000000 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isAddressValid, isPortValid } from './validate_address'; - -describe('Validate address', () => { - describe('isSeedNodeValid', () => { - describe('rejects', () => { - it('adjacent periods', () => { - expect(isAddressValid('a..b')).toBe(false); - }); - - it('underscores', () => { - expect(isAddressValid('____')).toBe(false); - }); - - ['/', '\\', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '=', '+', '?'].forEach( - (char) => { - it(char, () => { - expect(isAddressValid(char)).toBe(false); - }); - } - ); - }); - - describe('accepts', () => { - it('uppercase letters', () => { - expect(isAddressValid('A.B.C.D')).toBe(true); - }); - - it('lowercase letters', () => { - expect(isAddressValid('a')).toBe(true); - }); - - it('numbers', () => { - expect(isAddressValid('56546354')).toBe(true); - }); - - it('dashes', () => { - expect(isAddressValid('----')).toBe(true); - }); - - it('many parts', () => { - expect(isAddressValid('abcd.efgh.ijkl.mnop.qrst.uvwx.yz')).toBe(true); - }); - }); - }); - - describe('isPortValid', () => { - describe('rejects', () => { - it('missing port', () => { - expect(isPortValid('abcd')).toBe(false); - }); - - it('empty port', () => { - expect(isPortValid('abcd:')).toBe(false); - }); - - it('letters', () => { - expect(isPortValid('ab:cd')).toBe(false); - }); - - it('non-numbers', () => { - expect(isPortValid('ab:5 0')).toBe(false); - }); - - it('multiple ports', () => { - expect(isPortValid('ab:cd:9000')).toBe(false); - }); - }); - - describe('accepts', () => { - it('a single numeric port, even beyond the standard port range', () => { - expect(isPortValid('abcd:100000000')).toBe(true); - }); - }); - }); -}); diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.ts b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.ts deleted file mode 100644 index 7377454780d3c..0000000000000 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_address.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export function isAddressValid(seedNode?: string): boolean { - if (!seedNode) { - return false; - } - - const portParts = seedNode.split(':'); - const parts = portParts[0].split('.'); - const containsInvalidCharacters = parts.some((part) => { - if (!part) { - // no need to wait for regEx if the part is empty - return true; - } - const [match] = part.match(/[A-Za-z0-9\-]*/) ?? []; - return match !== part; - }); - - return !containsInvalidCharacters; -} - -export function isPortValid(seedNode?: string): boolean { - if (!seedNode) { - return false; - } - - const parts = seedNode.split(':'); - const hasOnePort = parts.length === 2; - - if (!hasOnePort) { - return false; - } - - const port = parts[1]; - - if (!port) { - return false; - } - - const isPortNumeric = (port.match(/[0-9]*/) ?? [])[0] === port; - return isPortNumeric; -} diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.test.ts b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.test.ts index 92170fe11dfe7..2c566481e48c2 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.test.ts +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.test.ts @@ -41,7 +41,7 @@ describe('Cloud remote address', () => { expect(actual).toBe(false); }); - it('false when proxy address is the same as server name', () => { + it('false when proxy address is the same as server name (hostname)', () => { const actual = isCloudAdvancedOptionsEnabled({ name: 'test', proxyAddress: 'some-proxy:9400', @@ -50,7 +50,25 @@ describe('Cloud remote address', () => { }); expect(actual).toBe(false); }); - it('true when proxy address is not the same as server name', () => { + it('false when proxy address is the same as server name (IPv4)', () => { + const actual = isCloudAdvancedOptionsEnabled({ + name: 'test', + proxyAddress: '1.1.1.1:9400', + serverName: '1.1.1.1', + securityModel: SECURITY_MODEL.CERTIFICATE, + }); + expect(actual).toBe(false); + }); + it('false when proxy address is the same as server name (IPv6)', () => { + const actual = isCloudAdvancedOptionsEnabled({ + name: 'test', + proxyAddress: '[2001:db8::1]:1234', + serverName: '[2001:db8::1]', + securityModel: SECURITY_MODEL.CERTIFICATE, + }); + expect(actual).toBe(false); + }); + it('true when proxy address is not the same as server name (hostname)', () => { const actual = isCloudAdvancedOptionsEnabled({ name: 'test', proxyAddress: 'some-proxy:9400', @@ -59,6 +77,24 @@ describe('Cloud remote address', () => { }); expect(actual).toBe(true); }); + it('true when proxy address is not the same as server name (IPv4)', () => { + const actual = isCloudAdvancedOptionsEnabled({ + name: 'test', + proxyAddress: '1.1.1.1:9400', + serverName: 'some-server-name', + securityModel: SECURITY_MODEL.CERTIFICATE, + }); + expect(actual).toBe(true); + }); + it('true when proxy address is not the same as server name (IPv6)', () => { + const actual = isCloudAdvancedOptionsEnabled({ + name: 'test', + proxyAddress: '[2001:db8::1]:1234', + serverName: 'some-server-name', + securityModel: SECURITY_MODEL.CERTIFICATE, + }); + expect(actual).toBe(true); + }); it('true when socket connections is not the default value', () => { const actual = isCloudAdvancedOptionsEnabled({ name: 'test', @@ -71,27 +107,57 @@ describe('Cloud remote address', () => { }); }); describe('conversion from cloud remote address', () => { - it('empty url to empty proxy connection values', () => { + it('converts empty url to empty proxy connection values', () => { const actual = convertCloudRemoteAddressToProxyConnection(''); expect(actual).toEqual({ proxyAddress: '', serverName: '' }); }); - it('url with protocol and port to proxy connection values', () => { + it('converts url with protocol and port to proxy connection values (IPv4)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('http://192.168.1.1:1234'); + expect(actual).toEqual({ proxyAddress: '192.168.1.1:1234', serverName: '192.168.1.1' }); + }); + + it('converts url with protocol and port to proxy connection values (IPv6)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('http://[2001:db8::1]:1234'); + expect(actual).toEqual({ proxyAddress: '[2001:db8::1]:1234', serverName: '[2001:db8::1]' }); + }); + + it('converts url with protocol and port to proxy connection values (hostname)', () => { const actual = convertCloudRemoteAddressToProxyConnection('http://test.com:1234'); expect(actual).toEqual({ proxyAddress: 'test.com:1234', serverName: 'test.com' }); }); - it('url with protocol and no port to proxy connection values', () => { + it('converts url with protocol and no port to proxy connection values (IPv4)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('http://192.168.1.1'); + expect(actual).toEqual({ proxyAddress: '192.168.1.1:9400', serverName: '192.168.1.1' }); + }); + + it('converts url with protocol and no port to proxy connection values (IPv6)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('http://[2001:db8::1]'); + expect(actual).toEqual({ proxyAddress: '[2001:db8::1]:9400', serverName: '[2001:db8::1]' }); + }); + + it('converts url with protocol and no port to proxy connection values (hostname)', () => { const actual = convertCloudRemoteAddressToProxyConnection('http://test.com'); expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); }); - it('url with no protocol to proxy connection values', () => { + it('converts url with no protocol to proxy connection values (IPv4)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('192.168.1.1'); + expect(actual).toEqual({ proxyAddress: '192.168.1.1:9400', serverName: '192.168.1.1' }); + }); + + it('converts url with no protocol to proxy connection values (IPv6)', () => { + const actual = convertCloudRemoteAddressToProxyConnection('2001:db8::1'); + expect(actual).toEqual({ proxyAddress: '2001:db8::1:9400', serverName: '2001:db8::1' }); + }); + + it('converts url with no protocol to proxy connection values (hostname)', () => { const actual = convertCloudRemoteAddressToProxyConnection('test.com'); expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' }); }); - it('invalid url to empty proxy connection values', () => { + it('converts invalid url to empty proxy connection values', () => { const actual = convertCloudRemoteAddressToProxyConnection('invalid%url'); expect(actual).toEqual({ proxyAddress: '', serverName: '' }); }); diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.tsx b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.tsx index ce739f1880634..26b87b4dcff57 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.tsx +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_cloud_url.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Cluster } from '../../../../../../../common/lib'; -import { isAddressValid } from './validate_address'; +import { isAddressValid, extractHostAndPort } from '../../../../../../../common/lib'; export const i18nTexts = { urlEmpty: ( @@ -39,9 +39,9 @@ export const isCloudAdvancedOptionsEnabled = (cluster?: Cluster): boolean => { if (!proxyAddress) { return false; } - const proxyAddressWithoutPort = (proxyAddress ?? '').split(':')[0]; + const { host } = extractHostAndPort(proxyAddress) ?? {}; return ( - proxyAddressWithoutPort !== serverName || + host !== serverName || (proxySocketConnections != null && proxySocketConnections !== DEFAULT_SOCKET_CONNECTIONS) ); }; @@ -58,8 +58,8 @@ export const convertCloudRemoteAddressToProxyConnection = (url: string) => { if (!url || !isAddressValid(url)) { return EMPTY_PROXY_VALUES; } - const host = url.split(':')[0]; - const port = url.split(':')[1]; + + const { host, port } = extractHostAndPort(url) ?? {}; const proxyAddress = port ? url : `${host}:${CLOUD_DEFAULT_PROXY_PORT}`; return { proxyAddress, serverName: host }; }; diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.test.ts b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.test.ts index c8ac35dbf8bc6..89a2f8c3ae40f 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.test.ts +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.test.ts @@ -23,4 +23,34 @@ describe('validateProxy', () => { test(`accepts valid proxy address`, () => { expect(validateProxy('localhost:3000')).toBe(null); }); + + describe('IPv4 addresses', () => { + test('accepts valid IPv4 address with port', () => { + expect(validateProxy('192.168.1.1:9300')).toBeNull(); + }); + + test('rejects valid IPv4 address without port', () => { + expect(validateProxy('192.168.1.1')).toMatchSnapshot(); + }); + + test('rejects invalid IPv4 address', () => { + expect(validateProxy('999.999.999.999')).toMatchSnapshot(); + }); + }); + + describe('IPv6 addresses', () => { + test('accepts valid IPv6 address with brackets and port', () => { + expect(validateProxy('[2001:db8::1]:9300')).toBeNull(); + }); + + test('rejects IPv6 address without brackets and port', () => { + // Without brackets, port is not recognized as such + // and it's either invalid or part of the ipv6 host + expect(validateProxy('2001:db8::1:9300')).toMatchSnapshot(); + }); + + test('rejects invalid IPv6 address', () => { + expect(validateProxy('[gggg::1]')).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.tsx b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.tsx index fd539839ba6f7..af8ab9415673a 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.tsx +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_proxy.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isAddressValid, isPortValid } from './validate_address'; +import { isAddressValid, isPortValid } from '../../../../../../../common/lib'; export function validateProxy(proxy?: string): null | JSX.Element { if (!proxy) { @@ -26,7 +26,7 @@ export function validateProxy(proxy?: string): null | JSX.Element { return ( ); diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.test.ts b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.test.ts index 4a3f96c601e1f..3ebc1093d3bfc 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.test.ts +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.test.ts @@ -22,4 +22,40 @@ describe('validateSeeds', () => { const errorsCount = validateSeed('seed:10').length; expect(errorsCount).toBe(0); }); + + describe('IPv4 addresses', () => { + test('accepts valid IPv4 address with port', () => { + const errorsCount = validateSeed('192.168.1.1:9300').length; + expect(errorsCount).toBe(0); + }); + + test('rejects valid IPv4 address without port', () => { + const errorsCount = validateSeed('192.168.1.1').length; + expect(errorsCount).toBeGreaterThan(0); + }); + + test('rejects invalid IPv4 address', () => { + const errorsCount = validateSeed('999.999.999.999').length; + expect(errorsCount).toBeGreaterThan(0); + }); + }); + + describe('IPv6 addresses', () => { + test('accepts valid IPv6 address with brackets and port', () => { + const errorsCount = validateSeed('[2001:db8::1]:9300').length; + expect(errorsCount).toBe(0); + }); + + test('rejects IPv6 address without brackets and port', () => { + // Without brackets, port is not recognized as such + // and it's either invalid or part of the ipv6 host + const errorsCount = validateSeed('2001:db8::1:9300').length; + expect(errorsCount).toBeGreaterThan(0); + }); + + test('rejects invalid IPv6 address', () => { + const errorsCount = validateSeed('[gggg::1]').length; + expect(errorsCount).toBeGreaterThan(0); + }); + }); }); diff --git a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.tsx b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.tsx index cdc92f8e89d2b..c3c5df266e362 100644 --- a/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.tsx +++ b/x-pack/platform/plugins/private/remote_clusters/public/application/sections/components/remote_cluster_config_steps/remote_cluster_form/validators/validate_seed.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isAddressValid, isPortValid } from './validate_address'; +import { isAddressValid, isPortValid } from '../../../../../../../common/lib'; export function validateSeed(seed?: string): JSX.Element[] { const errors: JSX.Element[] = []; @@ -22,7 +22,7 @@ export function validateSeed(seed?: string): JSX.Element[] { errors.push( ); }