diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 878eb83134c63..42618c432d6c3 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -94275,6 +94275,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true @@ -95477,6 +95478,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true @@ -97738,6 +97740,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index b1c2b4bb413d6..a8605746cc0c0 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -106685,6 +106685,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true @@ -107887,6 +107888,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true @@ -110148,6 +110150,7 @@ components: - none type: string compression_level: + nullable: true type: number config_yaml: nullable: true diff --git a/x-pack/platform/plugins/shared/fleet/common/services/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/index.ts index a7e8d5c3d222c..6a06ff737560f 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/index.ts @@ -131,6 +131,8 @@ export { // Cloud Connector accessor module export * from './cloud_connectors'; +export { validateSslCertPath } from './ssl_validators'; + export type { YamlModule } from './yaml_utils'; export { createYamlKeysSorter, toYaml } from './yaml_utils'; export { diff --git a/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.test.ts new file mode 100644 index 0000000000000..c1da1a99584ae --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { validateSslCertPath } from './ssl_validators'; + +describe('validateSslCertPath', () => { + describe('valid inputs (returns undefined)', () => { + it('empty string', () => { + expect(validateSslCertPath('')).toBeUndefined(); + }); + + it('Linux path without spaces', () => { + expect(validateSslCertPath('/etc/certs/ca.pem')).toBeUndefined(); + }); + + it('relative path without spaces', () => { + expect(validateSslCertPath('./certs/ca.pem')).toBeUndefined(); + }); + + it('Windows absolute path without spaces', () => { + expect(validateSslCertPath('C:\\certs\\server.pem')).toBeUndefined(); + }); + + it('Windows forward-slash path without spaces', () => { + expect(validateSslCertPath('C:/certs/server.pem')).toBeUndefined(); + }); + + it('UNC path without spaces', () => { + expect(validateSslCertPath('\\\\server\\share\\cert.pem')).toBeUndefined(); + }); + + it('PEM certificate content', () => { + expect( + validateSslCertPath( + '-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAJC1\n-----END CERTIFICATE-----' + ) + ).toBeUndefined(); + }); + + it('PEM RSA private key', () => { + expect( + validateSslCertPath( + '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----' + ) + ).toBeUndefined(); + }); + + it('PEM EC private key', () => { + expect( + validateSslCertPath( + '-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIO\n-----END EC PRIVATE KEY-----' + ) + ).toBeUndefined(); + }); + + it('PEM content with leading whitespace', () => { + expect( + validateSslCertPath(' -----BEGIN CERTIFICATE-----\nMIID\n-----END CERTIFICATE-----') + ).toBeUndefined(); + }); + }); + + describe('invalid inputs (returns error string)', () => { + it('Linux path with spaces', () => { + expect(validateSslCertPath('/path/to my cert.pem')).toBeDefined(); + }); + + it('relative path with spaces', () => { + expect(validateSslCertPath('./my certs/ca.pem')).toBeDefined(); + }); + + it('Windows path with spaces', () => { + expect(validateSslCertPath('C:\\Program Files\\certs\\server.pem')).toBeDefined(); + }); + + it('Windows forward-slash path with spaces', () => { + expect(validateSslCertPath('C:/Program Files/certs/server.pem')).toBeDefined(); + }); + + it('UNC path with spaces in share name', () => { + expect(validateSslCertPath('\\\\server\\my share\\cert.pem')).toBeDefined(); + }); + + it('path with tab character', () => { + expect(validateSslCertPath('/path/to\tcert.pem')).toBeDefined(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.ts b/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.ts new file mode 100644 index 0000000000000..54c94683e2a67 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/ssl_validators.ts @@ -0,0 +1,18 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +// PEM content (-----BEGIN ...) is exempt — it naturally contains whitespace +export function validateSslCertPath(value: string): string | undefined { + if (!value || value.trimStart().startsWith('-----BEGIN')) return undefined; + if (/\s/.test(value)) { + return i18n.translate('xpack.fleet.sslValidation.pathSpacesError', { + defaultMessage: 'SSL certificate path cannot contain whitespace', + }); + } +} diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/output.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/output.ts index 7f54edde2eaa5..644d583b82928 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/output.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/output.ts @@ -102,7 +102,7 @@ export interface KafkaOutput extends NewBaseOutput { version?: string; key?: string; compression?: ValueOf; - compression_level?: number; + compression_level?: number | null; auth_type?: ValueOf; connection_type?: ValueOf; username?: string | null; diff --git a/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings.cy.ts b/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings.cy.ts index cb8dd8f92c179..0f984b595032c 100644 --- a/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings.cy.ts +++ b/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings.cy.ts @@ -136,8 +136,12 @@ describe('Edit settings', () => { cy.getBySel(SETTINGS_OUTPUTS.TYPE_INPUT).select('logstash'); cy.get('[placeholder="Specify host"').clear().type('logstash:5044'); cy.getBySel(SETTINGS_OUTPUTS.SSL_BUTTON).click(); - cy.get('[placeholder="Specify SSL certificate"]').clear().type('SSL CERTIFICATE'); - cy.get('[placeholder="Specify certificate key"]').clear().type('SSL KEY'); + cy.get('[placeholder="Specify SSL certificate"]') + .clear() + .type('-----BEGIN CERTIFICATE-----', { parseSpecialCharSequences: false }); + cy.get('[placeholder="Specify certificate key"]') + .clear() + .type('-----BEGIN PRIVATE KEY-----', { parseSpecialCharSequences: false }); cy.intercept('/api/fleet/outputs', { items: [ @@ -157,8 +161,8 @@ describe('Edit settings', () => { is_default: false, is_default_monitoring: false, ssl: { - certificate: "SSL CERTIFICATE');", - key: 'SSL KEY', + certificate: '-----BEGIN CERTIFICATE-----', + key: '-----BEGIN PRIVATE KEY-----', }, }).as('postLogstashOutput'); diff --git a/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings_outputs.cy.ts b/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings_outputs.cy.ts index dd34b996e411e..c5e69bd7dd661 100644 --- a/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings_outputs.cy.ts +++ b/x-pack/platform/plugins/shared/fleet/cypress/e2e/fleet_settings_outputs.cy.ts @@ -182,12 +182,11 @@ queue: describe('Remote ES', () => { it('displays proper error messages', () => { selectRemoteESOutput(); + cy.getBySel(SETTINGS_OUTPUTS.NAME_INPUT).type('name'); + cy.get('[placeholder="Specify host URL"').clear().type('https://localhost:5000'); cy.getBySel(SETTINGS_SAVE_BTN).click(); - cy.contains('Name is required'); - cy.contains('URL is required'); cy.contains('Service token is required'); - shouldDisplayError(SETTINGS_OUTPUTS.NAME_INPUT); shouldDisplayError('serviceTokenSecretInput'); }); @@ -392,18 +391,17 @@ queue: it('displays proper error messages', () => { selectKafkaOutput(); + cy.getBySel(SETTINGS_OUTPUTS.NAME_INPUT).type('name'); + cy.get('[placeholder="Specify host"').type('localhost:5000'); cy.getBySel(SETTINGS_OUTPUTS_KAFKA.HEADERS_CLIENT_ID_INPUT).clear(); cy.getBySel(SETTINGS_SAVE_BTN).click(); - cy.contains('Name is required'); - cy.contains('Host is required'); cy.contains('Username is required'); cy.contains('Password is required'); cy.contains('Default topic is required'); cy.contains( 'Client ID is invalid. Only letters, numbers, dots, underscores, and dashes are allowed.' ); - shouldDisplayError(SETTINGS_OUTPUTS.NAME_INPUT); shouldDisplayError(SETTINGS_OUTPUTS_KAFKA.AUTHENTICATION_USERNAME_INPUT); shouldDisplayError(SETTINGS_OUTPUTS_KAFKA.AUTHENTICATION_PASSWORD_INPUT); shouldDisplayError(SETTINGS_OUTPUTS_KAFKA.TOPICS_DEFAULT_TOPIC_INPUT); @@ -564,8 +562,12 @@ queue: cy.get('[placeholder="Specify host"').clear().type('localhost:5000'); cy.getBySel(SETTINGS_OUTPUTS.SSL_BUTTON).click(); - cy.get('[placeholder="Specify SSL certificate"]').clear().type('SSL CERTIFICATE'); - cy.get('[placeholder="Specify certificate key"]').clear().type('SSL KEY'); + cy.get('[placeholder="Specify SSL certificate"]') + .clear() + .type('-----BEGIN CERTIFICATE-----', { parseSpecialCharSequences: false }); + cy.get('[placeholder="Specify certificate key"]') + .clear() + .type('-----BEGIN PRIVATE KEY-----', { parseSpecialCharSequences: false }); cy.intercept('PUT', '**/api/fleet/outputs/**').as('saveOutput'); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx index c35235491a9df..f4b96d1fdcaac 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx @@ -5,12 +5,116 @@ * 2.0. */ +import { act } from '@testing-library/react'; + +import { createFleetTestRendererMock } from '../../../../../../mock'; + import { validateHost, validateDownloadSourceHeaders, + useDowloadSourceFlyoutForm, type AuthType, } from './use_download_source_flyout_form'; +jest.mock('../../../../../../hooks/use_authz', () => ({ + useAuthz: () => ({ + fleet: { + allSettings: true, + }, + }), +})); + +describe('useDowloadSourceFlyoutForm SSL certificate path validation', () => { + it('should block submission when certificate path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => + useDowloadSourceFlyoutForm(onSuccess, undefined) + ); + + act(() => { + result.current.inputs.nameInput.setValue('My Source'); + result.current.inputs.hostInput.setValue('https://artifacts.example.com'); + result.current.inputs.sslCertificateInput.setValue('/path with spaces/cert.pem'); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.sslCertificateInput.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should block submission when certificate key path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => + useDowloadSourceFlyoutForm(onSuccess, undefined) + ); + + act(() => { + result.current.inputs.nameInput.setValue('My Source'); + result.current.inputs.hostInput.setValue('https://artifacts.example.com'); + result.current.inputs.sslKeyInput.setValue('/path with spaces/key.pem'); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.sslKeyInput.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should block submission when certificate authorities path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => + useDowloadSourceFlyoutForm(onSuccess, undefined) + ); + + act(() => { + result.current.inputs.nameInput.setValue('My Source'); + result.current.inputs.hostInput.setValue('https://artifacts.example.com'); + result.current.inputs.sslCertificateAuthoritiesInput.props.onChange([ + '/path with spaces/ca.pem', + ]); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.sslCertificateAuthoritiesInput.props.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should allow submission when all SSL paths are valid', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + testRenderer.startServices.http.post.mockResolvedValue({ item: {} }); + const { result } = testRenderer.renderHook(() => + useDowloadSourceFlyoutForm(onSuccess, undefined) + ); + + act(() => { + result.current.inputs.nameInput.setValue('My Source'); + result.current.inputs.hostInput.setValue('https://artifacts.example.com'); + result.current.inputs.sslCertificateInput.setValue('/valid/path/cert.pem'); + result.current.inputs.sslKeyInput.setValue('/valid/path/key.pem'); + result.current.inputs.sslCertificateAuthoritiesInput.props.onChange(['/valid/ca.pem']); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => expect(onSuccess).toBeCalled()); + }); +}); + describe('Download source form validation', () => { describe('validateHost', () => { it('should not work without any urls', () => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx index f761fc82586ee..e6eae7a9cb02a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx @@ -23,6 +23,12 @@ import { useConfirmModal } from '../../hooks/use_confirm_modal'; import type { DownloadSourceBase } from '../../../../../../../common/types'; +import { + validateSslPathInput, + validateSslPathInputSecret, + validateSslPathsCombo, +} from '../ssl_form_validators'; + import { confirmUpdate } from './confirm_update'; export type AuthType = 'none' | 'username_password' | 'api_key'; @@ -77,19 +83,19 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource const sslCertificateAuthoritiesInput = useComboInput( 'sslCertificateAuthoritiesComboxBox', downloadSource?.ssl?.certificate_authorities ?? [], - undefined, + validateSslPathsCombo, undefined ); const sslCertificateInput = useInput( downloadSource?.ssl?.certificate ?? '', - undefined, + validateSslPathInput, undefined ); - const sslKeyInput = useInput(downloadSource?.ssl?.key ?? '', undefined, undefined); + const sslKeyInput = useInput(downloadSource?.ssl?.key ?? '', validateSslPathInput, undefined); const sslKeySecretInput = useSecretInput( (downloadSource as DownloadSourceBase)?.secrets?.ssl?.key, - undefined, + validateSslPathInputSecret, undefined ); @@ -158,6 +164,7 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource const nameInputValid = nameInput.validate(); const hostValid = hostInput.validate(); + const sslCertificateAuthoritiesValid = sslCertificateAuthoritiesInput.validate(); const sslCertificateValid = sslCertificateInput.validate(); const sslKeyValid = sslKeyInput.validate(); const sslKeySecretValid = sslKeySecretInput.validate(); @@ -212,6 +219,7 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource return ( nameInputValid && hostValid && + sslCertificateAuthoritiesValid && sslCertificateValid && sslKeyValid && sslKeySecretValid && @@ -226,6 +234,7 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource }, [ nameInput, hostInput, + sslCertificateAuthoritiesInput, sslCertificateInput, sslKeyInput, sslKeySecretInput, @@ -357,11 +366,26 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource validate, ]); + const authType = authTypeInput.value as AuthType; + const isAuthMissing = + (authType === 'username_password' && + (!usernameInput.value || (!passwordInput.value && !passwordSecretInput.value))) || + (authType === 'api_key' && !apiKeyInput.value && !apiKeySecretInput.value); + return { inputs, submit, isLoading, - isDisabled: isLoading || (downloadSource && !hasChanged) || isEditDisabled, + isDisabled: + isLoading || + (downloadSource && !hasChanged) || + isEditDisabled || + !nameInput.value || + !hostInput.value || + isAuthMissing || + sslCertificateAuthoritiesInput.props.isInvalid || + sslCertificateInput.props.isInvalid || + (sslKeyInput.value ? sslKeyInput.props.isInvalid : sslKeySecretInput.props.isInvalid), }; } diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.test.tsx index 7e3d3f87137da..e6de9cf48e8f6 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.test.tsx @@ -11,6 +11,11 @@ import { createFleetTestRendererMock } from '../../../../../../mock'; import { useFleetProxyForm } from './use_fleet_proxy_form'; +jest.mock('../../hooks/use_confirm_modal', () => ({ + ...jest.requireActual('../../hooks/use_confirm_modal'), + useConfirmModal: () => ({ confirm: () => true }), +})); + jest.mock('../../../../../../hooks/use_authz', () => ({ useAuthz: () => ({ fleet: { @@ -53,4 +58,68 @@ describe('useFleetProxyForm', () => { expect(result.current.inputs.urlInput.errors).toEqual(['Invalid URL']); }); }); + + describe('SSL certificate path validation', () => { + it('should block submission when certificate path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => useFleetProxyForm(undefined, onSuccess)); + + act(() => { + result.current.inputs.nameInput.setValue('My Proxy'); + result.current.inputs.urlInput.setValue('http://proxy.example.com:3128'); + result.current.inputs.certificateInput.setValue('/path with spaces/cert.pem'); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.certificateInput.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should block submission when certificate key path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => useFleetProxyForm(undefined, onSuccess)); + + act(() => { + result.current.inputs.nameInput.setValue('My Proxy'); + result.current.inputs.urlInput.setValue('http://proxy.example.com:3128'); + result.current.inputs.certificateKeyInput.setValue('/path with spaces/key.pem'); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.certificateKeyInput.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should allow submission with valid certificate path', () => { + const testRenderer = createFleetTestRendererMock(); + const { result } = testRenderer.renderHook(() => useFleetProxyForm(undefined, () => {})); + + act(() => result.current.inputs.certificateInput.setValue('/valid/path/cert.pem')); + act(() => expect(result.current.inputs.certificateInput.validate()).toBeTruthy()); + expect(result.current.inputs.certificateInput.errors).toBeUndefined(); + }); + + it('should allow PEM certificate content regardless of spaces', () => { + const testRenderer = createFleetTestRendererMock(); + const { result } = testRenderer.renderHook(() => useFleetProxyForm(undefined, () => {})); + + act(() => + result.current.inputs.certificateInput.setValue( + '-----BEGIN CERTIFICATE-----\nMIID\n-----END CERTIFICATE-----' + ) + ); + act(() => expect(result.current.inputs.certificateInput.validate()).toBeTruthy()); + expect(result.current.inputs.certificateInput.errors).toBeUndefined(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.tsx index 1436e40315cf6..f67bd5274e56c 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_fleet_proxy_flyout/use_fleet_proxy_form.tsx @@ -20,6 +20,7 @@ import { import { useYaml } from '../../../../../../services'; import { useConfirmModal } from '../../hooks/use_confirm_modal'; +import { validateSslPathInput } from '../ssl_form_validators'; import type { FleetProxy } from '../../../../types'; import { PROXY_URL_REGEX } from '../../../../../../../common/constants'; @@ -118,13 +119,17 @@ export function useFleetProxyForm(fleetProxy: FleetProxy | undefined, onSuccess: ); const certificateAuthoritiesInput = useInput( fleetProxy?.certificate_authorities ?? '', - () => undefined, + validateSslPathInput, + isEditDisabled + ); + const certificateInput = useInput( + fleetProxy?.certificate ?? '', + validateSslPathInput, isEditDisabled ); - const certificateInput = useInput(fleetProxy?.certificate ?? '', () => undefined, isEditDisabled); const certificateKeyInput = useInput( fleetProxy?.certificate_key ?? '', - () => undefined, + validateSslPathInput, isEditDisabled ); @@ -211,7 +216,15 @@ export function useFleetProxyForm(fleetProxy: FleetProxy | undefined, onSuccess: const hasChanged = Object.values(inputs).some((input) => input.hasChanged); const isDisabled = - isLoading || !hasChanged || nameInput.props.isInvalid || urlInput.props.isInvalid; + isLoading || + !hasChanged || + !nameInput.value || + !urlInput.value || + nameInput.props.isInvalid || + urlInput.props.isInvalid || + certificateAuthoritiesInput.props.isInvalid || + certificateInput.props.isInvalid || + certificateKeyInput.props.isInvalid; return { isLoading, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index bcdb634452619..24a7eedf81211 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -8,6 +8,9 @@ import { i18n } from '@kbn/i18n'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { validateSslPathInput, validateSslPathsCombo } from '../ssl_form_validators'; +export { validateSslPathInput, validateSslPathsCombo }; + const toSecretValidator = (validator: (value: string) => string[] | undefined) => (value: string | { id: string } | undefined) => { @@ -336,6 +339,7 @@ export function validateSSLCertificate(value: string) { }), ]; } + return validateSslPathInput(value); } export function validateSSLKey(value: string) { @@ -346,6 +350,7 @@ export function validateSSLKey(value: string) { }), ]; } + return validateSslPathInput(value); } export const validateSSLKeySecret = toSecretValidator(validateSSLKey); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx index 8f1eb4eaf097e..2880ce18c03a2 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx @@ -77,6 +77,8 @@ import { validateDynamicKafkaTopics, validateKibanaURL, validateKibanaAPIKey, + validateSslPathInput, + validateSslPathsCombo, } from './output_form_validators'; import { confirmUpdate } from './confirm_update'; @@ -418,25 +420,29 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp const sslCertificateAuthoritiesInput = useComboInput( 'sslCertificateAuthoritiesComboxBox', output?.ssl?.certificate_authorities ?? [], - undefined, + validateSslPathsCombo, isSSLEditable ); const sslCertificateInput = useInput( output?.ssl?.certificate ?? '', - output?.type === 'logstash' && logstashEnableSSLInput.value + typeInput.value === 'logstash' && logstashEnableSSLInput.value ? validateSSLCertificate - : undefined, + : validateSslPathInput, isSSLEditable ); const sslKeyInput = useInput( output?.ssl?.key ?? '', - output?.type === 'logstash' && logstashEnableSSLInput.value ? validateSSLKey : undefined, + typeInput.value === 'logstash' && logstashEnableSSLInput.value + ? validateSSLKey + : validateSslPathInput, isSSLEditable ); const sslKeySecretInput = useSecretInput( (output as NewLogstashOutput)?.secrets?.ssl?.key, - output?.type === 'logstash' && logstashEnableSSLInput.value ? validateSSLKeySecret : undefined, + typeInput.value === 'logstash' && logstashEnableSSLInput.value + ? validateSSLKeySecret + : undefined, isSSLEditable ); @@ -491,7 +497,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp const kafkaSslCertificateAuthoritiesInput = useComboInput( 'kafkaSslCertificateAuthoritiesComboBox', kafkaOutput?.ssl?.certificate_authorities ?? [], - undefined, + validateSslPathsCombo, isSSLEditable ); const kafkaSslCertificateInput = useInput( @@ -705,6 +711,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp const kafkaPasswordPlainValid = kafkaAuthPasswordInput.validate(); const kafkaPasswordSecretValid = kafkaAuthPasswordSecretInput.validate(); const kafkaClientIDValid = kafkaClientIdInput.validate(); + const kafkaSslCertificateAuthoritiesValid = kafkaSslCertificateAuthoritiesInput.validate(); const kafkaSslCertificateValid = kafkaSslCertificateInput.validate(); const kafkaSslKeyPlainValid = kafkaSslKeyInput.validate(); const kafkaSslKeySecretValid = kafkaSslKeySecretInput.validate(); @@ -717,6 +724,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp const serviceTokenSecretValid = serviceTokenSecretInput.validate(); const kibanaAPIKeyValid = kibanaAPIKeyInput.validate(); const kibanaURLInputValid = kibanaURLInput.validate(); + const sslCertificateAuthoritiesValid = sslCertificateAuthoritiesInput.validate(); const sslCertificateValid = sslCertificateInput.validate(); const sslKeyValid = sslKeyInput.validate(); const sslKeySecretValid = sslKeySecretInput.validate(); @@ -740,6 +748,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp logstashHostsValid && additionalYamlConfigValid && nameInputValid && + sslCertificateAuthoritiesValid && sslCertificateValid && (sslKeyValid || sslKeySecretValid) ); @@ -749,6 +758,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp return ( nameInputValid && kafkaHostsValid && + kafkaSslCertificateAuthoritiesValid && kafkaSslCertificateValid && kafkaSslKeyValid && kafkaUsernameValid && @@ -767,6 +777,9 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp remoteElasticsearchUrlsValid && additionalYamlConfigValid && nameInputValid && + sslCertificateAuthoritiesValid && + sslCertificateValid && + sslKeyValid && ((serviceTokenInput.value && serviceTokenValid) || (serviceTokenSecretInput.value && serviceTokenSecretValid)) && ((!syncIntegrationsInput.value && kibanaURLInputValid) || @@ -783,7 +796,10 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp otelExporterConfigValid && nameInputValid && caTrustedFingerprintValid && - diskQueuePathValid + diskQueuePathValid && + sslCertificateAuthoritiesValid && + sslCertificateValid && + sslKeyValid ); } }, [ @@ -795,6 +811,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp kafkaAuthPasswordInput, kafkaAuthPasswordSecretInput, kafkaClientIdInput, + kafkaSslCertificateAuthoritiesInput, kafkaSslCertificateInput, kafkaSslKeyInput, kafkaSslKeySecretInput, @@ -808,6 +825,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp kibanaAPIKeyInput, syncIntegrationsInput, kibanaURLInput, + sslCertificateAuthoritiesInput, sslCertificateInput, sslKeyInput, sslKeySecretInput, @@ -1206,6 +1224,14 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp notifications.toasts, ]); + const isHostsMissing = isKafka + ? !kafkaHostsInput.value.some((v) => v.trim()) + : isLogstash + ? !logstashHostsInput.value.some((v) => v.trim()) + : isRemoteElasticsearch + ? !remoteElasticsearchUrlInput.value.some((v) => v.trim()) + : !elasticsearchUrlInput.value.some((v) => v.trim()); + return { inputs, submit, @@ -1213,6 +1239,22 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOutp hasEncryptedSavedObjectConfigured, isShipperEnabled: !isShipperDisabled, isDisabled: - isLoading || (output && !hasChanged) || (isLogstash && !hasEncryptedSavedObjectConfigured), + isLoading || + (output && !hasChanged) || + !nameInput.value || + isHostsMissing || + (isLogstash && !hasEncryptedSavedObjectConfigured) || + (!isKafka && + (sslCertificateAuthoritiesInput.props.isInvalid || + sslCertificateInput.props.isInvalid || + (sslKeySecretInput.value + ? sslKeySecretInput.props.isInvalid + : sslKeyInput.props.isInvalid))) || + (isKafka && + (kafkaSslCertificateAuthoritiesInput.props.isInvalid || + kafkaSslCertificateInput.props.isInvalid || + (kafkaSslKeySecretInput.value + ? kafkaSslKeySecretInput.props.isInvalid + : kafkaSslKeyInput.props.isInvalid))), }; } diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.test.tsx index d833f292ccd7a..e349512089855 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.test.tsx @@ -73,7 +73,7 @@ describe('useFleetServerHostsForm', () => { it('should submit a valid form with SSL options', async () => { const testRenderer = createFleetTestRendererMock(); const onSuccess = jest.fn(); - testRenderer.startServices.http.post.mockResolvedValue({}); + testRenderer.startServices.http.put.mockResolvedValue({}); const { result } = testRenderer.renderHook(() => useFleetServerHostsForm( { @@ -83,13 +83,13 @@ describe('useFleetServerHostsForm', () => { is_default: false, is_preconfigured: false, ssl: { - certificate_authorities: ['cert authorities'], - es_certificate_authorities: ['ES cert authorities'], - certificate: 'path/to/cert', - es_certificate: 'path/to/EScert', - agent_certificate_authorities: ['agent cert authorities'], - agent_certificate: 'path/to/agent_cert', - agent_key: 'path/to/agent_key', + certificate_authorities: ['/etc/certs/ca.pem'], + es_certificate_authorities: ['/etc/certs/es-ca.pem'], + certificate: '/etc/certs/cert.pem', + es_certificate: '/etc/certs/es-cert.pem', + agent_certificate_authorities: ['/etc/certs/agent-ca.pem'], + agent_certificate: '/etc/certs/agent-cert.pem', + agent_key: '/etc/certs/agent-key.pem', }, }, onSuccess @@ -103,6 +103,94 @@ describe('useFleetServerHostsForm', () => { await testRenderer.waitFor(() => expect(onSuccess).toBeCalled()); }); + describe('SSL certificate path validation', () => { + it('should block submission when a certificate path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => + useFleetServerHostsForm( + { + id: 'id1', + name: 'fleet server 1', + host_urls: ['https://test.fr'], + is_default: false, + is_preconfigured: false, + }, + onSuccess + ) + ); + + act(() => result.current.inputs.sslCertificateInput.setValue('/path with spaces/cert.pem')); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.sslCertificateInput.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should block submission when an ES certificate authorities path contains spaces', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + const { result } = testRenderer.renderHook(() => + useFleetServerHostsForm( + { + id: 'id1', + name: 'fleet server 1', + host_urls: ['https://test.fr'], + is_default: false, + is_preconfigured: false, + }, + onSuccess + ) + ); + + act(() => + result.current.inputs.sslEsCertificateAuthoritiesInput.props.onChange([ + '/path with spaces/ca.pem', + ]) + ); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => { + expect(result.current.inputs.sslEsCertificateAuthoritiesInput.props.errors).toBeDefined(); + expect(onSuccess).not.toBeCalled(); + expect(result.current.isDisabled).toBeTruthy(); + }); + }); + + it('should allow submission when all SSL paths are valid', async () => { + const testRenderer = createFleetTestRendererMock(); + const onSuccess = jest.fn(); + testRenderer.startServices.http.put.mockResolvedValue({}); + const { result } = testRenderer.renderHook(() => + useFleetServerHostsForm( + { + id: 'id1', + name: 'fleet server 1', + host_urls: ['https://test.fr'], + is_default: false, + is_preconfigured: false, + }, + onSuccess + ) + ); + + act(() => { + result.current.inputs.sslCertificateInput.setValue('/valid/path/cert.pem'); + result.current.inputs.sslKeyInput.setValue('/valid/path/key.pem'); + result.current.inputs.sslCertificateAuthoritiesInput.props.onChange(['/valid/ca.pem']); + }); + + await act(() => result.current.submit()); + + await testRenderer.waitFor(() => expect(onSuccess).toBeCalled()); + }); + }); + it('should allow the user to correct and submit an invalid form', async () => { const testRenderer = createFleetTestRendererMock(); const onSuccess = jest.fn(); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.tsx index 3355e3b9b827f..74fa428ac0a98 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/use_fleet_server_host_form.tsx @@ -22,6 +22,7 @@ import { useSecretInput, } from '../../../../hooks'; import { isDiffPathProtocol } from '../../../../../../../common/services'; +import { validateSslPathInput, validateSslPathsCombo } from '../ssl_form_validators'; import { useConfirmModal } from '../../hooks/use_confirm_modal'; import type { FleetServerHost } from '../../../../types'; import type { ClientAuth, NewFleetServerHost, ValueOf } from '../../../../../../../common/types'; @@ -172,28 +173,36 @@ export function useFleetServerHostsForm( const sslCertificateAuthoritiesInput = useComboInput( 'sslCertificateAuthoritiesComboxBox', fleetServerHost?.ssl?.certificate_authorities ?? [], - undefined, + validateSslPathsCombo, isEditDisabled ); const sslCertificateInput = useInput( fleetServerHost?.ssl?.certificate ?? '', - () => undefined, + validateSslPathInput, isEditDisabled ); const sslEsCertificateAuthoritiesInput = useComboInput( 'sslEsCertificateAuthoritiesComboxBox', fleetServerHost?.ssl?.es_certificate_authorities ?? [], - undefined, + validateSslPathsCombo, isEditDisabled ); const sslEsCertificateInput = useInput( fleetServerHost?.ssl?.es_certificate ?? '', - () => undefined, + validateSslPathInput, + isEditDisabled + ); + const sslKeyInput = useInput( + fleetServerHost?.ssl?.key ?? '', + validateSslPathInput, + isEditDisabled + ); + const sslESKeyInput = useInput( + fleetServerHost?.ssl?.es_key ?? '', + validateSslPathInput, isEditDisabled ); - const sslKeyInput = useInput(fleetServerHost?.ssl?.key ?? '', undefined, isEditDisabled); - const sslESKeyInput = useInput(fleetServerHost?.ssl?.es_key ?? '', undefined, isEditDisabled); const sslKeySecretInput = useSecretInput( (fleetServerHost as FleetServerHost)?.secrets?.ssl?.key, @@ -215,12 +224,12 @@ export function useFleetServerHostsForm( const sslAgentCertificateAuthoritiesInput = useComboInput( 'sslAgentCertificateAuthoritiesComboxBox', fleetServerHost?.ssl?.agent_certificate_authorities ?? [], - undefined, + validateSslPathsCombo, isEditDisabled ); const sslAgentCertificateInput = useInput( fleetServerHost?.ssl?.agent_certificate ?? '', - () => undefined, + validateSslPathInput, isEditDisabled ); const sslAgentKeySecretInput = useSecretInput( @@ -230,7 +239,7 @@ export function useFleetServerHostsForm( ); const sslAgentKeyInput = useInput( fleetServerHost?.ssl?.agent_key ?? '', - undefined, + validateSslPathInput, isEditDisabled ); @@ -378,8 +387,19 @@ export function useFleetServerHostsForm( isEditDisabled || isLoading || !hasChanged || + !nameInput.value || + !hostUrlsInput.value.some((v) => v.trim()) || hostUrlsInput.props.isInvalid || - nameInput.props.isInvalid; + nameInput.props.isInvalid || + sslCertificateAuthoritiesInput.props.isInvalid || + sslCertificateInput.props.isInvalid || + sslKeyInput.props.isInvalid || + sslEsCertificateAuthoritiesInput.props.isInvalid || + sslEsCertificateInput.props.isInvalid || + sslESKeyInput.props.isInvalid || + sslAgentCertificateAuthoritiesInput.props.isInvalid || + sslAgentCertificateInput.props.isInvalid || + sslAgentKeyInput.props.isInvalid; return { isLoading, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/ssl_form_validators.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/ssl_form_validators.ts new file mode 100644 index 0000000000000..6ee5dbefe6c2f --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/ssl_form_validators.ts @@ -0,0 +1,31 @@ +/* + * 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 { validateSslCertPath } from '../../../../../../common/services'; + +export function validateSslPathInput(value: string): string[] | undefined { + const err = validateSslCertPath(value); + return err ? [err] : undefined; +} + +// For useSecretInput: skips existing secret references ({ id }), validates plain strings +export function validateSslPathInputSecret( + value: string | { id: string } | undefined +): string[] | undefined { + if (!value || typeof value === 'object') return undefined; + return validateSslPathInput(value); +} + +export function validateSslPathsCombo( + values: string[] +): Array<{ message: string; index: number }> | undefined { + const errors = values + .map((v, index) => ({ err: validateSslCertPath(v), index })) + .filter((x): x is { err: string; index: number } => x.err !== undefined) + .map(({ err, index }) => ({ message: err, index })); + return errors.length ? errors : undefined; +} diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts index 16757762cc63c..7040d1d03ffde 100644 --- a/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts @@ -34,8 +34,11 @@ export function useInput( (e: React.ChangeEvent) => { const newValue = e.target.value; setValue(newValue); - if (errors && validate && validate(newValue) === undefined) { - setErrors(undefined); + if (errors && validate) { + const newErrors = validate(newValue); + if (newErrors?.join() !== errors.join()) { + setErrors(newErrors); + } } }, [errors, validate] @@ -98,8 +101,11 @@ export function useSecretInput( (e: React.ChangeEvent) => { const newValue = e.target.value; setValue(newValue); - if (errors && validate && validate(newValue) === undefined) { - setErrors(undefined); + if (errors && validate) { + const newErrors = validate(newValue); + if (newErrors?.join() !== errors.join()) { + setErrors(newErrors); + } } }, [errors, validate] diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts index 15f6f15cafda6..f68a92a49330c 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts @@ -25,6 +25,7 @@ import type { } from '../../../common/types'; import { downloadSourceService } from '../../services/download_source'; import { agentPolicyService } from '../../services'; +import { throwIfSslPathInvalid } from '../utils/ssl_utils'; // Support clearing auth via PUT requests export type DownloadSourceWithNullableAuth = Partial & { @@ -42,6 +43,13 @@ export type DownloadSourceWithNullableAuth = Partial & { * - auth: undefined (no changes to auth) */ export function validateDownloadSource(downloadSource: DownloadSourceWithNullableAuth) { + throwIfSslPathInvalid([ + ...(downloadSource.ssl?.certificate_authorities ?? []), + downloadSource.ssl?.certificate, + downloadSource.ssl?.key, + downloadSource.secrets?.ssl?.key, + ]); + // For settings that can be stored as secrets, only allow either plain text or secret reference. if (downloadSource.ssl?.key && downloadSource.secrets?.ssl?.key) { throw Boom.badRequest('Cannot specify both ssl.key and secrets.ssl.key'); diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/fleet_proxies/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/fleet_proxies/handler.ts index deee49c15f8b6..d7c06c11a835a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/fleet_proxies/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/fleet_proxies/handler.ts @@ -32,6 +32,7 @@ import type { } from '../../types'; import { agentPolicyService } from '../../services'; import { MAX_CONCURRENT_AGENT_POLICIES_OPERATIONS_20 } from '../../constants'; +import { throwIfSslPathInvalid } from '../utils/ssl_utils'; async function bumpRelatedPolicies( soClient: SavedObjectsClientContract, @@ -81,6 +82,7 @@ export const postFleetProxyHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const { id, ...data } = request.body; + throwIfSslPathInvalid([data.certificate_authorities, data.certificate, data.certificate_key]); const proxy = await createFleetProxy(soClient, { ...data, is_preconfigured: false }, { id }); const body = { @@ -101,6 +103,11 @@ export const putFleetProxyHandler: RequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; + throwIfSslPathInvalid([ + request.body.certificate_authorities, + request.body.certificate, + request.body.certificate_key, + ]); const item = await updateFleetProxy(soClient, proxyId, request.body); const body = { item, diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/fleet_server_hosts/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/fleet_server_hosts/handler.ts index e695a1c27f6b1..6e68b752f5301 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/fleet_server_hosts/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/fleet_server_hosts/handler.ts @@ -9,9 +9,9 @@ import type { TypeOf } from '@kbn/config-schema'; import type { RequestHandler, SavedObjectsClientContract } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { isEqual } from 'lodash'; - import Boom from '@hapi/boom'; +import { throwIfSslPathInvalid } from '../utils/ssl_utils'; import { SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID } from '../../constants'; import { FleetServerHostUnauthorizedError } from '../../errors'; @@ -24,6 +24,23 @@ import type { PutFleetServerHostRequestSchema, } from '../../types'; +function validateFleetServerHostSsl(fleetServerHost: Partial) { + throwIfSslPathInvalid([ + ...(fleetServerHost.ssl?.certificate_authorities ?? []), + fleetServerHost.ssl?.certificate, + fleetServerHost.ssl?.key, + ...(fleetServerHost.ssl?.es_certificate_authorities ?? []), + fleetServerHost.ssl?.es_certificate, + fleetServerHost.ssl?.es_key, + ...(fleetServerHost.ssl?.agent_certificate_authorities ?? []), + fleetServerHost.ssl?.agent_certificate, + fleetServerHost.ssl?.agent_key, + fleetServerHost.secrets?.ssl?.key, + fleetServerHost.secrets?.ssl?.es_key, + fleetServerHost.secrets?.ssl?.agent_key, + ]); +} + function ensureNoDuplicateSecrets(fleetServerHost: Partial) { if (fleetServerHost.ssl?.key && fleetServerHost.secrets?.ssl?.key) { throw Boom.badRequest('Cannot specify both ssl.key and secrets.ssl.key'); @@ -69,6 +86,7 @@ export const postFleetServerHost: RequestHandler< await checkFleetServerHostsWriteAPIsAllowed(soClient, request.body.host_urls); const { id, ...data } = request.body; + validateFleetServerHostSsl(data); ensureNoDuplicateSecrets(data); const FleetServerHost = await fleetServerHostService.create( @@ -147,6 +165,7 @@ export const putFleetServerHostHandler: RequestHandler< if (request.body.host_urls) { await checkFleetServerHostsWriteAPIsAllowed(soClient, request.body.host_urls); } + validateFleetServerHostSsl(request.body); ensureNoDuplicateSecrets(request.body); const item = await fleetServerHostService.update( diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/output/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/output/handler.ts index 93aff9f4c1335..8ebbd63393c30 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/output/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/output/handler.ts @@ -32,6 +32,16 @@ import { outputService } from '../../services/output'; import { FleetUnauthorizedError } from '../../errors'; import { agentPolicyService, appContextService } from '../../services'; import { generateLogstashApiKey, canCreateLogstashApiKey } from '../../services/api_keys'; +import { throwIfSslPathInvalid } from '../utils/ssl_utils'; + +function validateOutputSslPaths(output: Partial) { + throwIfSslPathInvalid([ + ...(output.ssl?.certificate_authorities ?? []), + output.ssl?.certificate, + output.ssl?.key, + output.secrets?.ssl?.key, + ]); +} function ensureNoDuplicateSecrets(output: Partial) { if (output.type === outputType.Kafka && output?.password && output?.secrets?.password) { @@ -93,6 +103,7 @@ export const putOutputHandler: RequestHandler< const outputUpdate = request.body; try { await validateOutputServerless(outputUpdate, soClient, request.params.outputId); + validateOutputSslPaths(outputUpdate); ensureNoDuplicateSecrets(outputUpdate); await outputService.update(soClient, esClient, request.params.outputId, outputUpdate); const output = await outputService.get(request.params.outputId); @@ -128,6 +139,7 @@ export const postOutputHandler: RequestHandler< const esClient = coreContext.elasticsearch.client.asInternalUser; const { id, ...newOutput } = request.body; await validateOutputServerless(newOutput, soClient); + validateOutputSslPaths(newOutput); ensureNoDuplicateSecrets(newOutput); const output = await outputService.create(soClient, esClient, newOutput, { id }); if (output.is_default || output.is_default_monitoring) { diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/utils/ssl_utils.ts b/x-pack/platform/plugins/shared/fleet/server/routes/utils/ssl_utils.ts new file mode 100644 index 0000000000000..a0e81e78b1802 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/routes/utils/ssl_utils.ts @@ -0,0 +1,21 @@ +/* + * 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 Boom from '@hapi/boom'; + +import { validateSslCertPath } from '../../../common/services'; + +export function throwIfSslPathInvalid( + paths: Array | undefined | null> +) { + for (const p of paths) { + // SOSecret values can be a plain string or a { id } secret reference — skip references + if (!p || typeof p === 'object') continue; + const err = validateSslCertPath(p); + if (err) throw Boom.badRequest(err); + } +} diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/output.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/output.ts index 86157b65bcb69..d894249eb5030 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/models/output.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/models/output.ts @@ -236,7 +236,7 @@ export const KafkaSchema = { schema.literal(kafkaCompressionType.None), ]) ), - compression_level: schema.maybe(schema.number()), + compression_level: schema.maybe(schema.oneOf([schema.literal(null), schema.number()])), client_id: schema.maybe(schema.string()), auth_type: schema.oneOf([ schema.literal(kafkaAuthType.None), diff --git a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts index 3f9f9d27c698c..5c04241aecbe6 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts @@ -339,8 +339,8 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], - certificate: 'path/to/cert', + certificate_authorities: ['/path/to/cert-authority'], + certificate: '/path/to/cert', key: 'KEY', }, secrets: { ssl: { key: 'KEY' } }, @@ -361,7 +361,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'KEY1' } }, @@ -689,7 +689,7 @@ export default function (providerContext: FtrProviderContext) { host: 'https://test.co', is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'KEY1' } }, @@ -715,7 +715,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'KEY1' } }, @@ -735,7 +735,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'NEW_KEY' } }, @@ -770,7 +770,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', key: 'KEY1', }, @@ -786,7 +786,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'NEW_KEY' } }, @@ -1135,7 +1135,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'SSL_KEY_VALUE' } }, @@ -1161,7 +1161,7 @@ export default function (providerContext: FtrProviderContext) { expect(updateRes.item.secrets.ssl.key.id).to.equal(secretId); expect(updateRes.item.ssl.certificate).to.equal('path/to/cert'); - expect(updateRes.item.ssl.certificate_authorities).to.eql(['cert authorities']); + expect(updateRes.item.ssl.certificate_authorities).to.eql(['/path/to/cert-authority']); const secretAfterUpdate = await getSecretById(secretId); // @ts-ignore _source unknown type @@ -1227,7 +1227,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { @@ -1560,7 +1560,7 @@ export default function (providerContext: FtrProviderContext) { host: 'http://test.fr:443', is_default: false, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', }, secrets: { ssl: { key: 'KEY1' } }, diff --git a/x-pack/platform/test/fleet_api_integration/apis/fleet_server_hosts/crud.ts b/x-pack/platform/test/fleet_api_integration/apis/fleet_server_hosts/crud.ts index 1d38fa36491fd..983a6d16d6b3c 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/fleet_server_hosts/crud.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/fleet_server_hosts/crud.ts @@ -193,10 +193,10 @@ export default function (providerContext: FtrProviderContext) { host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, secrets: { ssl: { key: 'KEY1', es_key: 'KEY2' } }, }) @@ -238,10 +238,10 @@ export default function (providerContext: FtrProviderContext) { host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, }) .expect(200); @@ -297,10 +297,10 @@ export default function (providerContext: FtrProviderContext) { is_default: true, id, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], key: 'KEY', }, secrets: { ssl: { key: 'KEY' } }, @@ -322,10 +322,10 @@ export default function (providerContext: FtrProviderContext) { is_default: true, id, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], es_key: 'KEY', }, secrets: { ssl: { es_key: 'KEY' } }, @@ -346,10 +346,10 @@ export default function (providerContext: FtrProviderContext) { host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, secrets: { ssl: { key: 'KEY1', es_key: 'KEY2' } }, }) @@ -394,10 +394,10 @@ export default function (providerContext: FtrProviderContext) { .send({ name: 'Default', ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, }) .expect(200); @@ -409,9 +409,11 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(fleetServerHost.ssl.certificate).to.eql('path/to/cert'); - expect(fleetServerHost.ssl.certificate_authorities).to.eql(['cert authorities']); + expect(fleetServerHost.ssl.certificate_authorities).to.eql(['/path/to/cert-authority']); expect(fleetServerHost.ssl.es_certificate).to.eql('path/to/EScert'); - expect(fleetServerHost.ssl.es_certificate_authorities).to.eql(['ES cert authorities']); + expect(fleetServerHost.ssl.es_certificate_authorities).to.eql([ + '/path/to/es-cert-authority', + ]); }); it('should return a 404 when updating a non existing fleet server host', async function () { @@ -435,10 +437,10 @@ export default function (providerContext: FtrProviderContext) { host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, secrets: { ssl: { key: 'KEY1', es_key: 'KEY2' } }, }) @@ -457,10 +459,10 @@ export default function (providerContext: FtrProviderContext) { host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], is_default: true, ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, secrets: { ssl: { key: 'NEW_KEY' } }, }) @@ -517,10 +519,10 @@ export default function (providerContext: FtrProviderContext) { name: `Default ${Date.now()}`, host_urls: ['https://test.fr:8080', 'https://test.fr:8081'], ssl: { - certificate_authorities: ['cert authorities'], + certificate_authorities: ['/path/to/cert-authority'], certificate: 'path/to/cert', es_certificate: 'path/to/EScert', - es_certificate_authorities: ['ES cert authorities'], + es_certificate_authorities: ['/path/to/es-cert-authority'], }, secrets: { ssl: { es_key: 'KEY2' } }, })