diff --git a/packages/forklift-console-plugin/package.json b/packages/forklift-console-plugin/package.json index 4ecae9d8a..64624276d 100644 --- a/packages/forklift-console-plugin/package.json +++ b/packages/forklift-console-plugin/package.json @@ -41,6 +41,7 @@ "jsonpath": "^1.1.1", "jsrsasign": "11.1.0", "luxon": "^3.5.0", + "node-forge": "^1", "react": "17.0.2", "react-dom": "17.0.2", "react-i18next": "^11.14.3", diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/common.ts b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/common.ts index f7fb6ecf4..8a1a10a1a 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/common.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/common.ts @@ -25,6 +25,7 @@ const QUERY_PARAMS = '(\\?[a-zA-Z0-9=&_]*)?'; const URL_REGEX = new RegExp( `^${PROTOCOL}((${IPV4})|(${HOSTNAME}))((${PORT})(${PATH})(${QUERY_PARAMS})?)?$`, ); +const IPV4_REGEX = new RegExp(IPV4); // validate NFS mount NFS_SERVER:EXPORTED_DIRECTORY // example: 10.10.0.10:/backups @@ -78,6 +79,10 @@ export function validateURL(url: string) { return URL_REGEX.test(url); } +export function validateIpv4(value: string) { + return IPV4_REGEX.test(value); +} + export function validateNFSMount(nfsPath: string) { return NFS_REGEX.test(nfsPath); } diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/providerValidator.ts b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/providerValidator.ts index 65c0e2e89..d887cf943 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/providerValidator.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/providerValidator.ts @@ -25,7 +25,7 @@ export function providerValidator( validationError = ovirtProviderValidator(provider); break; case 'vsphere': - validationError = vsphereProviderValidator(provider); + validationError = vsphereProviderValidator(provider, secret?.data?.cacert); break; case 'ova': validationError = ovaProviderValidator(provider); diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/validateVCenterURL.ts b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/validateVCenterURL.ts index f86894775..d1e5166f2 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/validateVCenterURL.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/validateVCenterURL.ts @@ -1,6 +1,26 @@ -import { validateURL, ValidationMsg } from '../../common'; +import { pki } from 'node-forge'; -export const validateVCenterURL = (url: string | number): ValidationMsg => { +import { safeBase64Decode } from '../../../helpers'; +import { validateIpv4, validateURL, ValidationMsg } from '../../common'; + +export const urlMatchesCertFqdn = (urlHostname: string, caCert: string): boolean => { + try { + const decodedCaCert = safeBase64Decode(caCert); + const cert = pki.certificateFromPem(decodedCaCert); + const dnsAltName = cert.extensions + .find((ext) => ext.name === 'subjectAltName') + ?.altNames.find((altName) => altName.type === 2)?.value; + const commonName = cert.subject.attributes.find((attr) => attr.name === 'commonName')?.value; + + return urlHostname === (dnsAltName || commonName); + } catch (e) { + console.error('Unable to parse certificate object from PEM.'); + } + + return false; +}; + +export const validateVCenterURL = (url: string, caCert?: string): ValidationMsg => { // For a newly opened form where the field is not set yet, set the validation type to default. if (url === undefined) { return { @@ -16,6 +36,7 @@ export const validateVCenterURL = (url: string | number): ValidationMsg => { const trimmedUrl: string = url.trim(); const isValidURL = validateURL(trimmedUrl); + const urlHostname = getUrlHostname(url); if (trimmedUrl === '') { return { @@ -37,8 +58,32 @@ export const validateVCenterURL = (url: string | number): ValidationMsg => { type: 'warning', }; + if (validateIpv4(urlHostname)) { + return { + type: 'error', + msg: 'Invalid URL. The URL must be a fully qualified domain name (FQDN).', + }; + } + + if (caCert && !urlMatchesCertFqdn(urlHostname, caCert)) { + return { + type: 'error', + msg: 'Invalid URL. The URL must be a fully qualified domain name (FQDN) and match the FQDN in the certificate you uploaded.', + }; + } + return { type: 'success', msg: 'The URL of the vCenter API endpoint for example: https://host-example.com/sdk .', }; }; + +const getUrlHostname = (url: string) => { + try { + return new URL(url)?.hostname; + } catch { + console.error('Unable to parse URL.'); + } + + return ''; +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/vsphereProviderValidator.ts b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/vsphereProviderValidator.ts index 567bab130..e17b73020 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/vsphereProviderValidator.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/validators/provider/vsphere/vsphereProviderValidator.ts @@ -2,9 +2,13 @@ import { V1beta1Provider } from '@kubev2v/types'; import { validateK8sName, validateURL, ValidationMsg } from '../../common'; +import { validateVCenterURL } from './validateVCenterURL'; import { validateVDDKImage } from './validateVDDKImage'; -export function vsphereProviderValidator(provider: V1beta1Provider): ValidationMsg { +export function vsphereProviderValidator( + provider: V1beta1Provider, + caCert?: string, +): ValidationMsg { const name = provider?.metadata?.name; const url = provider?.spec?.url || ''; const vddkInitImage = provider?.spec?.settings?.['vddkInitImage'] || ''; @@ -16,7 +20,7 @@ export function vsphereProviderValidator(provider: V1beta1Provider): ValidationM return { type: 'error', msg: 'invalid kubernetes resource name' }; } - if (!validateURL(url)) { + if (caCert ? validateVCenterURL(url, caCert).type === 'error' : !validateURL(url)) { return { type: 'error', msg: 'invalid URL' }; } diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx index d0ebbf423..3f2bef948 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx @@ -71,7 +71,11 @@ export const EditProvider: React.FC = ({ default: return ( <> - + diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/VCenterProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/VCenterProviderCreateForm.tsx index a2c9febd4..c0944e219 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/VCenterProviderCreateForm.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/VCenterProviderCreateForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { validateVCenterURL, validateVDDKImage, @@ -14,11 +14,13 @@ import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; export interface VCenterProviderCreateFormProps { provider: V1beta1Provider; + caCert: string; onChange: (newValue: V1beta1Provider) => void; } export const VCenterProviderCreateForm: React.FC = ({ provider, + caCert, onChange, }) => { const { t } = useForkliftTranslation(); @@ -31,11 +33,19 @@ export const VCenterProviderCreateForm: React.FC const initialState = { validation: { - url: validateVCenterURL(url), + url: validateVCenterURL(url, caCert), vddkInitImage: validateVDDKImage(vddkInitImage), }, }; + // When certificate changes, re-validate the URL + useEffect(() => { + dispatch({ + type: 'SET_FIELD_VALIDATED', + payload: { field: 'url', validationState: validateVCenterURL(url, caCert) }, + }); + }, [caCert]); + const reducer = (state, action) => { switch (action.type) { case 'SET_FIELD_VALIDATED': @@ -93,7 +103,7 @@ export const VCenterProviderCreateForm: React.FC spec: { ...provider?.spec, settings: { - ...(provider?.spec?.settings as object), + ...provider?.spec?.settings, vddkInitImage: trimmedValue, }, }, @@ -108,7 +118,7 @@ export const VCenterProviderCreateForm: React.FC spec: { ...provider?.spec, settings: { - ...(provider?.spec?.settings as object), + ...provider?.spec?.settings, sdkEndpoint: sdkEndpoint, }, }, @@ -117,14 +127,14 @@ export const VCenterProviderCreateForm: React.FC if (id === 'url') { // Validate URL - VCenter of ESXi - const validationState = validateVCenterURL(trimmedValue); + const validationState = validateVCenterURL(trimmedValue, caCert); dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: 'url', validationState } }); onChange({ ...provider, spec: { ...provider.spec, url: trimmedValue } }); } }, - [provider], + [provider, caCert], ); const onClick: (event: React.MouseEvent) => void = (event) => {