From b31f4a1a97736043a18e2dc2741ddd0667ec0128 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 28 Apr 2021 15:26:47 -0400 Subject: [PATCH] [actions] adds config allowing per-host networking options (#96630) resolves: https://github.com/elastic/kibana/issues/80120 Adds a new Kibana configuration key xpack.actions.customHostSettings which allows per-host configuration of connection settings for https and smtp for alerting actions. Initially this is just for TLS settings, expandable to other settings in the future. The purpose of these is to allow customers to provide server certificates for servers accessed by actions, whose certificate authority is not available publicly. Alternatively, a per-server rejectUnauthorized: false configuration may be used to bypass the verification step for specific servers, but require it for other servers that do not have per-host customization. Support was also added to allow per-host customization of ignoreTLS and requireTLS flags for use with the email action. --- docs/settings/alert-action-settings.asciidoc | 91 +++- .../alerting-troubleshooting.asciidoc | 16 + .../resources/base/bin/kibana-docker | 1 + .../actions/server/actions_config.mock.ts | 1 + .../actions/server/actions_config.test.ts | 81 +++ .../plugins/actions/server/actions_config.ts | 27 +- .../server/builtin_action_types/email.test.ts | 2 + .../lib/axios_utils_connection.test.ts | 277 ++++++++++ .../lib/get_custom_agents.test.ts | 118 ++++ .../lib/get_custom_agents.ts | 45 +- .../lib/send_email.test.ts | 144 ++++- .../builtin_action_types/lib/send_email.ts | 35 +- .../server/builtin_action_types/teams.test.ts | 2 + .../builtin_action_types/webhook.test.ts | 2 + x-pack/plugins/actions/server/config.test.ts | 13 + x-pack/plugins/actions/server/config.ts | 25 + .../server/lib/custom_host_settings.test.ts | 504 ++++++++++++++++++ .../server/lib/custom_host_settings.ts | 173 ++++++ x-pack/plugins/actions/server/plugin.ts | 6 +- .../alerting_api_integration/common/config.ts | 65 ++- .../common/lib/get_tls_webhook_servers.ts | 78 +++ .../tests/actions/get_all.ts | 24 +- .../spaces_only/config.ts | 1 + .../actions/builtin_action_types/webhook.ts | 89 ++++ .../spaces_only/tests/actions/get_all.ts | 37 +- 25 files changed, 1840 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts create mode 100644 x-pack/plugins/actions/server/lib/custom_host_settings.test.ts create mode 100644 x-pack/plugins/actions/server/lib/custom_host_settings.ts create mode 100644 x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c748d63484e2..50ed0d2652c6 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -47,6 +47,88 @@ You can configure the following settings in the `kibana.yml` file. | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. + +| `xpack.actions.customHostSettings` {ess-icon} + | A list of custom host settings to override existing global settings. + Defaults to an empty list. + + + + Each entry in the list must have a `url` property, to associate a connection + type (mail or https), hostname and port with the remaining options in the + entry. + + + In the following example, two custom host settings + are defined. The first provides a custom host setting for mail server + `mail.example.com` using port 465 that supplies server certificate authorization + data from both a file and inline, and requires TLS for the + connection. The second provides a custom host setting for https server + `webhook.example.com` which turns off server certificate authorization. + +|=== + +[source,yaml] +-- +xpack.actions.customHostSettings: + - url: smtp://mail.example.com:465 + tls: + certificateAuthoritiesFiles: [ 'one.crt' ] + certificateAuthoritiesData: | + -----BEGIN CERTIFICATE----- + ... multiple lines of certificate data here ... + -----END CERTIFICATE----- + smtp: + requireTLS: true + - url: https://webhook.example.com + tls: + rejectUnauthorized: false +-- + +[cols="2*<"] +|=== + +| `xpack.actions.customHostSettings[n]` +`.url` {ess-icon} + | A URL associated with this custom host setting. Should be in the form of + `protocol://hostname:port`, where `protocol` is `https` or `smtp`. If the + port is not provided, 443 is used for `https` and 25 is used for + `smtp`. The `smtp` URLs are used for the Email actions that use this + server, and the `https` URLs are used for actions which use `https` to + connect to services. + + + + Entries with `https` URLs can use the `tls` options, and entries with `smtp` + URLs can use both the `tls` and `smtp` options. + + + + No other URL values should be part of this URL, including paths, + query strings, and authentication information. When an http or smtp request + is made as part of executing an action, only the protocol, hostname, and + port of the URL for that request are used to look up these configuration + values. + +| `xpack.actions.customHostSettings[n]` +`.smtp.ignoreTLS` {ess-icon} + | A boolean value indicating that TLS must not be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.smtp.requireTLS` {ess-icon} + | A boolean value indicating that TLS must be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.tls.rejectUnauthorized` {ess-icon} + | A boolean value indicating whether to bypass server certificate validation. + Overrides the general `xpack.actions.rejectUnauthorized` configuration + for requests made for this hostname/port. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesFiles` + | A file name or list of file names of PEM-encoded certificate files to use + to validate the server. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesData` {ess-icon} + | The contents of a PEM-encoded certificate file, or multiple files appended + into a single string. This configuration can be used for environments where + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -79,13 +161,18 @@ a|`xpack.actions.` | `xpack.actions.rejectUnauthorized` {ess-icon} | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + - As an alternative to setting both `xpack.actions.proxyRejectUnauthorizedCertificates` and `xpack.actions.rejectUnauthorized`, you can point the OS level environment variable `NODE_EXTRA_CA_CERTS` to a file that contains the root CAs needed to trust certificates. + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. | `xpack.actions.maxResponseContentLength` {ess-icon} | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} - | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as [ms|s|m|h|d|w|M|Y], for example, '20m', '24h', '7d', '1w'. Defaults to 60s. + | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + + + `[ms,s,m,h,d,w,M,Y]` + + + + For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. |=== diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index f4673d10bc24..6d4a0e937567 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -53,3 +53,19 @@ Alerting and action tasks are identified by their type. When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +=== Connectors have TLS errors when executing actions + +*Problem*: + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Resolution*: + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 220bd2c91057..3b2feeecabb7 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -162,6 +162,7 @@ kibana_vars=( timelion.enabled vega.enableExternalUrls xpack.actions.allowedHosts + xpack.actions.customHostSettings xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfiguredAlertHistoryEsIndex diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 76f6a62ce659..fbd9a8cddbdc 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -21,6 +21,7 @@ const createActionsConfigMock = () => { maxContentLength: 1000000, timeout: 360000, }), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 70c8b0e8185d..925e77ca85fb 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -13,8 +13,14 @@ import { AllowedHosts, EnabledActionTypes, } from './actions_config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; +import { Logger } from '../../../../src/core/server'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; + import moment from 'moment'; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], @@ -355,4 +361,79 @@ describe('getProxySettings', () => { const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); }); + + test('getCustomHostSettings() returns undefined when no matching config', () => { + const httpsUrl = 'https://elastic.co/foo/bar'; + const smtpUrl = 'smtp://elastic.co'; + let config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://www.elastic.co:443', + }, + ], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + }); + + test('getCustomHostSettings() returns matching config', () => { + const httpsUrl = 'https://elastic.co/ignoring/paths/here'; + const smtpUrl = 'smtp://elastic.co:123'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'smtp://elastic.co:123', + tls: { + rejectUnauthorized: false, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(config.customHostSettings![0]); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(config.customHostSettings![1]); + }); + + test('getCustomHostSettings() returns undefined when bad url is passed in', () => { + const badUrl = 'https://elastic.co/foo/bar'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + const chs = getActionsConfigurationUtilities(config).getCustomHostSettings(badUrl); + expect(chs).toEqual(undefined); + }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 4c73cab76f9e..b8cd5878a897 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,8 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings } from './types'; @@ -32,6 +33,7 @@ export interface ActionsConfigurationUtilities { isRejectUnauthorizedCertificatesEnabled: () => boolean; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; + getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -107,6 +109,27 @@ function getResponseSettingsFromConfig(config: ActionsConfig): ResponseSettings }; } +function getCustomHostSettings( + config: ActionsConfig, + targetUrl: string +): CustomHostSettings | undefined { + const customHostSettings = config.customHostSettings; + if (!customHostSettings) { + return; + } + + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(targetUrl); + } catch (err) { + // presumably this bad URL is reported elsewhere + return; + } + + const canonicalUrl = getCanonicalCustomHostUrl(parsedUrl); + return customHostSettings.find((settings) => settings.url === canonicalUrl); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { @@ -119,6 +142,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), + // returns the global rejectUnauthorized setting isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { @@ -135,5 +159,6 @@ export function getActionsConfigurationUtilities( throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config'); } }, + getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4596619c5094..5747b4bbb28f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -282,6 +282,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -342,6 +343,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts new file mode 100644 index 000000000000..80bf51e19c37 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -0,0 +1,277 @@ +/* + * 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import http from 'http'; +import https from 'https'; +import axios from 'axios'; +import { duration as momentDuration } from 'moment'; +import { schema } from '@kbn/config-schema'; + +import { request } from './axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createReadySignal } from '../../../../event_log/server/lib/ready_signal'; +import { ActionsConfig } from '../../config'; +import { + ActionsConfigurationUtilities, + getActionsConfigurationUtilities, +} from '../../actions_config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeAll(() => { + // needed to prevent the dreaded Error: Cross origin http://localhost forbidden + // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 + savedAxiosDefaultsAdapter = axios.defaults.adapter; + axios.defaults.adapter = require('axios/lib/adapters/http'); + }); + + afterAll(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer.close(); + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer(); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with rejectUnauthorized false config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with rejectUnauthorized custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + rejectUnauthorized: false, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer(true); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 1', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 2', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); +}); + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(useHttps: boolean = false): Promise { + let server: http.Server | https.Server; + const readySignal = createReadySignal(); + + if (!useHttps) { + server = http.createServer((req, res) => { + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + }); + } else { + const httpsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, + }; + server = https.createServer(httpsOptions, (req, res) => { + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + } + + server.listen(() => { + const addressInfo = server.address(); + if (addressInfo == null || typeof addressInfo === 'string') { + server.close(); + throw new Error('error getting address of server, closing'); + } + + const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); + readySignal.signal({ server, url }); + }); + + // let the node process stop if for some reason this server isn't closed + server.unref(); + + return readySignal.wait(); +} + +const BaseActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + return getActionsConfigurationUtilities({ + ...BaseActionsConfig, + ...config, + }); +} + +function localUrlFromPort(useHttps: boolean, port: number, host: string): string { + return `${useHttps ? 'https' : 'http'}://${host}:${port}`; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index f6d1be9bffc6..805c22806ce4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const targetHost = 'elastic.co'; const targetUrl = `https://${targetHost}/foo/bar/baz`; +const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', @@ -106,4 +111,117 @@ describe('getCustomAgents', () => { expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); }); + + test('handles custom host settings', () => { + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles custom host settings with proxy', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles overriding global rejectUnauthorized false', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); + + test('handles overriding global rejectUnauthorized false with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index ff2d005f4d84..6ec926004e73 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -6,7 +6,7 @@ */ import { Agent as HttpAgent } from 'http'; -import { Agent as HttpsAgent } from 'https'; +import { Agent as HttpsAgent, AgentOptions } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; @@ -22,7 +22,8 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const proxySettings = configurationUtilities.getProxySettings(); + // the default for rejectUnauthorized is the global setting, which can + // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ @@ -30,10 +31,39 @@ export function getCustomAgents( }), }; + // Get the current proxy settings, and custom host settings for this URL. + // If there are neither of these, return the default agents + const proxySettings = configurationUtilities.getProxySettings(); + const customHostSettings = configurationUtilities.getCustomHostSettings(url); + if (!proxySettings && !customHostSettings) { + return defaultAgents; + } + + // update the defaultAgents.httpsAgent if configured + const tlsSettings = customHostSettings?.tls; + let agentOptions: AgentOptions | undefined; + if (tlsSettings) { + logger.debug(`Creating customized connection settings for: ${url}`); + agentOptions = defaultAgents.httpsAgent.options; + + if (tlsSettings.certificateAuthoritiesData) { + agentOptions.ca = tlsSettings.certificateAuthoritiesData; + } + + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + if (tlsSettings.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + } + } + + // if there weren't any proxy settings, return the currently calculated agents if (!proxySettings) { return defaultAgents; } + // there is a proxy in use, but it's possible we won't use it via custom host + // proxyOnlyHosts and proxyBypassHosts let targetUrl: URL; try { targetUrl = new URL(url); @@ -56,6 +86,7 @@ export function getCustomAgents( return defaultAgents; } } + logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { @@ -65,6 +96,9 @@ export function getCustomAgents( return defaultAgents; } + // At this point, we are going to use a proxy, so we need new agents. + // We will though, copy over the calculated tls options from above, into + // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ host: proxyUrl.hostname, @@ -76,5 +110,12 @@ export function getCustomAgents( }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it + if (agentOptions) { + httpsAgent.options = { + ...httpsAgent.options, + ...agentOptions, + }; + } + return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 4b45c6d787cd..cceeefde71dc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -356,16 +357,151 @@ describe('send_email module', () => { ] `); }); + + test('it handles custom host settings from config', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + }, + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // note in the object below, the rejectUnauthenticated got set to false, + // given the implementation allowing that for no auth and !secure. + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it allows custom host settings to override calculated values', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + requireTLS: false, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // in this case, rejectUnauthorized is true, as the custom host settings + // overrode the calculated value of false + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "ignoreTLS": true, + "port": 1025, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); + + test('it handles custom host settings with a proxy', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": undefined, + "host": "example.com", + "port": 1025, + "proxy": "https://proxy.com", + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); }); function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, @@ -392,12 +528,16 @@ function getSendEmailOptions( function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index c0a254967b4f..005e73b1fc2f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -11,6 +11,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { CustomHostSettings } from '../../config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -52,7 +53,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record = {}; + // The transport options do not seem to be exposed as a type, and we reference + // some deep properties, so need to use any here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); @@ -73,6 +77,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom useProxy = false; } } + let customHostSettings: CustomHostSettings | undefined; if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; @@ -83,6 +88,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.host = host; transportConfig.port = port; transportConfig.secure = !!secure; + customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { transportConfig.tls = { @@ -99,6 +105,33 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom } else { transportConfig.tls = { rejectUnauthorized }; } + + // finally, allow customHostSettings to override some of the settings + // see: https://nodemailer.com/smtp/ + if (customHostSettings) { + const tlsConfig: Record = {}; + const tlsSettings = customHostSettings.tls; + const smtpSettings = customHostSettings.smtp; + + if (tlsSettings?.certificateAuthoritiesData) { + tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + } + if (tlsSettings?.rejectUnauthorized !== undefined) { + tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; + } + + if (!transportConfig.tls) { + transportConfig.tls = tlsConfig; + } else { + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + } + + if (smtpSettings?.ignoreTLS) { + transportConfig.ignoreTLS = true; + } else if (smtpSettings?.requireTLS) { + transportConfig.requireTLS = true; + } + } } const nodemailerTransport = nodemailer.createTransport(transportConfig); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 8a185d353de0..95088fa5f796 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -167,6 +167,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -230,6 +231,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d3f059eede61..00e56303dbe2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -290,6 +290,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -382,6 +383,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 092b5d2cce58..4c4fd143369e 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -164,6 +164,19 @@ describe('config validation', () => { ] `); }); + + // Most of the customHostSettings tests are in ./lib/custom_host_settings.test.ts + // but this one seemed more relevant for this test suite, since url is the one + // required property. + test('validates customHostSettings contains a URL', () => { + const config: Record = { + customHostSettings: [{}], + }; + + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` + ); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 7225c54d5759..0dc1aed68f4d 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -23,6 +23,30 @@ const preconfiguredActionSchema = schema.object({ secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), }); +const customHostSettingsSchema = schema.object({ + url: schema.string({ minLength: 1 }), + smtp: schema.maybe( + schema.object({ + ignoreTLS: schema.maybe(schema.boolean()), + requireTLS: schema.maybe(schema.boolean()), + }) + ), + tls: schema.maybe( + schema.object({ + rejectUnauthorized: schema.maybe(schema.boolean()), + certificateAuthoritiesFiles: schema.maybe( + schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]) + ), + certificateAuthoritiesData: schema.maybe(schema.string({ minLength: 1 })), + }) + ), +}); + +export type CustomHostSettings = TypeOf; + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( @@ -50,6 +74,7 @@ export const configSchema = schema.object({ rejectUnauthorized: schema.boolean({ defaultValue: true }), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), + customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), cleanupFailedExecutionsTask: schema.object({ enabled: schema.boolean({ defaultValue: true }), cleanupInterval: schema.duration({ defaultValue: '5m' }), diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts new file mode 100644 index 000000000000..ad07ea21d791 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -0,0 +1,504 @@ +/* + * 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 { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +import { ActionsConfig } from '../config'; +import { Logger } from '../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; + +import { resolveCustomHosts, getCanonicalCustomHostUrl } from './custom_host_settings'; + +const CA_DIR = '../../../../../../packages/kbn-dev-utils/certs'; +const CA_FILE1 = pathResolve(__filename, pathJoin(CA_DIR, 'ca.crt')); +const CA_CONTENTS1 = fsReadFileSync(CA_FILE1, 'utf8'); +const CA_FILE2 = pathResolve(__filename, pathJoin(CA_DIR, 'kibana.crt')); +const CA_CONTENTS2 = fsReadFileSync(CA_FILE2, 'utf8'); + +let mockLogger: Logger = loggingSystemMock.create().get(); + +function warningLogs() { + const calls = loggingSystemMock.collect(mockLogger).warn; + return calls.map((call) => `${call[0]}`); +} + +describe('custom_host_settings', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockLogger = loggingSystemMock.create().get(); + }); + + describe('getCanonicalCustomHostUrl()', () => { + test('minimal urls', () => { + expect(getCanonicalCustomHostUrl(new URL('http://elastic.com'))).toBe( + 'http://elastic.com:80' + ); + expect(getCanonicalCustomHostUrl(new URL('https://elastic.co'))).toBe( + 'https://elastic.co:443' + ); + expect(getCanonicalCustomHostUrl(new URL('smtp://mail.elastic.co'))).toBe( + 'smtp://mail.elastic.co:25' + ); + expect(warningLogs()).toEqual([]); + }); + + test('maximal urls', () => { + expect( + getCanonicalCustomHostUrl(new URL('http://user1:pass1@elastic.co:81/foo?bar#car')) + ).toBe('http://elastic.co:81'); + expect( + getCanonicalCustomHostUrl(new URL('https://user1:pass1@elastic.co:82/foo?bar#car')) + ).toBe('https://elastic.co:82'); + expect( + getCanonicalCustomHostUrl(new URL('smtp://user1:pass1@mail.elastic.co:83/foo?bar#car')) + ).toBe('smtp://mail.elastic.co:83'); + expect(warningLogs()).toEqual([]); + }); + }); + + describe('resolveCustomHosts()', () => { + const defaultActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: [], + enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration(60000), + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, + }; + + test('ensure it copies over the config parts that it does not touch', () => { + const config: ActionsConfig = { ...defaultActionsConfig }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles undefined customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: undefined }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles empty object customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: [] }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple valid settings', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co:443', + tls: { + certificateAuthoritiesData: 'xyz', + rejectUnauthorized: false, + }, + }, + { + url: 'smtp://mail.elastic.com:25', + tls: { + certificateAuthoritiesData: 'abc', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles bad url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'this! is! not! a! url!', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, invalid URL \\"this! is! not! a! url!\\", ignoring; error: Invalid URL: this! is! not! a! url!", + ] + `); + }); + + test('handles bad port', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:0', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unable to determine port for URL \\"https://almost.purrfect.com:0\\", ignoring", + ] + `); + }); + + test('handles auth info', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://kitty:cat@almost.purrfect.com', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://kitty:cat@almost.purrfect.com\\" contains authentication information which will be ignored, but should be removed from the configuration", + ] + `); + }); + + test('handles hash', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com#important', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com#important\\" contains hash information which will be ignored", + ] + `); + }); + + test('handles path', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/about', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/about\\" contains path information which will be ignored", + ] + `); + }); + + test('handles / path same as no path, since we have no choice', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles unsupported URL protocols', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'http://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unsupported protocol used in URL \\"http://almost.purrfect.com/\\", ignoring", + ] + `); + }); + + test('handles smtp options for non-smtp urls', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/\\" contains smtp properties but does not use smtp; ignoring smtp properties", + ] + `); + }); + + test('handles ca files not found', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "error reading file \\"this-file-does-not-exist\\" specified in xpack.actions.customHosts, ignoring: ENOENT: no such file or directory, open 'this-file-does-not-exist'", + ] + `); + }); + + test('handles a single ca file', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: CA_FILE1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple ca files', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles ca files and ca data', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE2], + certificateAuthoritiesData: CA_CONTENTS1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles smtp ignoreTLS and requireTLS both used', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + requireTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com:25', + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"smtp://almost.purrfect.com/\\" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false", + ] + `); + }); + + test('handles duplicate URLs', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: false, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, multiple URLs match the canonical url \\"https://almost.purrfect.com:443\\"; only the first will be used", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts new file mode 100644 index 000000000000..bfc8dad48aab --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -0,0 +1,173 @@ +/* + * 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 { readFileSync } from 'fs'; +import { cloneDeep } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; +import { ActionsConfig, CustomHostSettings } from '../config'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +type ActionsConfigWriteable = DeepWriteable; +type CustomHostSettingsWriteable = DeepWriteable; + +export function getCanonicalCustomHostUrl(url: URL): string { + const port = getActualPort(url.protocol, url.port); + + return `${url.protocol}//${url.hostname}:${port}`; +} + +const ErrorPrefix = 'In configuration xpack.actions.customHosts,'; +const ValidProtocols = new Set(['https:', 'smtp:']); +const ProtocolsForSmtp = new Set(['smtp:']); + +// converts the custom host data in config, for ease of use, and to perform +// validation we can't do in config-schema, since the cloud validation can't +// do these sorts of validations +export function resolveCustomHosts(logger: Logger, config: ActionsConfig): ActionsConfig { + const result: ActionsConfigWriteable = cloneDeep(config); + + if (!result.customHostSettings) { + return result as ActionsConfig; + } + + const savedSettings: CustomHostSettingsWriteable[] = []; + + for (const customHostSetting of result.customHostSettings) { + const originalUrl = customHostSetting.url; + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(originalUrl); + } catch (err) { + logger.warn(`${ErrorPrefix} invalid URL "${originalUrl}", ignoring; error: ${err.message}`); + continue; + } + + customHostSetting.url = getCanonicalCustomHostUrl(parsedUrl); + + if (!ValidProtocols.has(parsedUrl.protocol)) { + logger.warn(`${ErrorPrefix} unsupported protocol used in URL "${originalUrl}", ignoring`); + continue; + } + + const port = getActualPort(parsedUrl.protocol, parsedUrl.port); + if (!port) { + logger.warn(`${ErrorPrefix} unable to determine port for URL "${originalUrl}", ignoring`); + continue; + } + + if (parsedUrl.username || parsedUrl.password) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains authentication information which will be ignored, but should be removed from the configuration` + ); + } + + if (parsedUrl.hash) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains hash information which will be ignored` + ); + } + + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains path information which will be ignored` + ); + } + + if (!ProtocolsForSmtp.has(parsedUrl.protocol) && customHostSetting.smtp) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains smtp properties but does not use smtp; ignoring smtp properties` + ); + delete customHostSetting.smtp; + } + + // read the specified ca files, add their content to certificateAuthoritiesData + if (customHostSetting.tls) { + let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (typeof files === 'string') { + files = [files]; + } + for (const file of files) { + const contents = getFileContents(logger, file); + if (contents) { + appendToCertificateAuthoritiesData(customHostSetting, contents); + } + } + } + + const customSmtpSettings = customHostSetting.smtp; + if (customSmtpSettings) { + if (customSmtpSettings.requireTLS && customSmtpSettings.ignoreTLS) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false` + ); + customSmtpSettings.requireTLS = true; + customSmtpSettings.ignoreTLS = false; + } + } + + savedSettings.push(customHostSetting); + } + + // check to see if there are any dups on the url + const existingUrls = new Set(); + for (const customHostSetting of savedSettings) { + const url = customHostSetting.url; + if (existingUrls.has(url)) { + logger.warn( + `${ErrorPrefix} multiple URLs match the canonical url "${url}"; only the first will be used` + ); + // mark this entry to be able to delete it after processing them all + customHostSetting.url = ''; + } + existingUrls.add(url); + } + + // remove the settings we want to skip + result.customHostSettings = savedSettings.filter((setting) => setting.url !== ''); + + return result as ActionsConfig; +} + +function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { + const tls = customHost.tls; + if (tls) { + if (!tls.certificateAuthoritiesData) { + tls.certificateAuthoritiesData = cert; + } else { + tls.certificateAuthoritiesData += '\n' + cert; + } + } +} + +function getFileContents(logger: Logger, fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch (err) { + logger.warn( + `error reading file "${fileName}" specified in xpack.actions.customHosts, ignoring: ${err.message}` + ); + return; + } +} + +// 0 isn't a valid port, so result can be checked as falsy +function getActualPort(protocol: string, port: string): number { + if (port !== '') { + const portNumber = parseInt(port, 10); + if (isNaN(portNumber)) { + return 0; + } + return portNumber; + } + + // from https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_port + if (protocol === 'http:') return 80; + if (protocol === 'https:') return 443; + if (protocol === 'smtp:') return 25; + return 0; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 106e41259e69..2036ed6c7d34 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,6 +35,7 @@ import { } from './cleanup_failed_executions'; import { ActionsConfig, getValidatedConfig } from './config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction } from './create_execute_function'; @@ -157,7 +158,10 @@ export class ActionsPlugin implements Plugin()); + this.actionsConfig = getValidatedConfig( + this.logger, + resolveCustomHosts(this.logger, initContext.config.get()) + ); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 6a0ab5408784..7844eaf3920c 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -12,6 +12,7 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { getAllExternalServiceSimulatorPaths } from './fixtures/plugins/actions_simulators/server/plugin'; +import { getTlsWebhookServerUrls } from './lib/get_tls_webhook_servers'; interface CreateTestConfigOptions { license: string; @@ -21,6 +22,7 @@ interface CreateTestConfigOptions { rejectUnauthorized?: boolean; publicBaseUrl?: boolean; preconfiguredAlertHistoryEsIndex?: boolean; + customizeLocalHostTls?: boolean; } // test.not-enabled is specifically not enabled @@ -49,6 +51,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ssl = false, rejectUnauthorized = true, preconfiguredAlertHistoryEsIndex = false, + customizeLocalHostTls = false, } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -69,7 +72,11 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ); const proxyPort = - process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6300) })); + process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6299) })); + + // Create URLs of identical simple webhook servers using TLS, but we'll + // create custom host settings for them below. + const tlsWebhookServers = await getTlsWebhookServerUrls(6300, 6399); // If testing with proxy, also test proxyOnlyHosts for this proxy; // all the actions are assumed to be acccessing localhost anyway. @@ -89,6 +96,32 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.proxyBypassHosts=${JSON.stringify(proxyHosts)}`, ]; + // set up custom host settings for webhook ports; don't set one for noCustom + const customHostSettingsValue = [ + { + url: tlsWebhookServers.rejectUnauthorizedFalse, + tls: { + rejectUnauthorized: false, + }, + }, + { + url: tlsWebhookServers.rejectUnauthorizedTrue, + tls: { + rejectUnauthorized: true, + }, + }, + { + url: tlsWebhookServers.caFile, + tls: { + rejectUnauthorized: true, + certificateAuthoritiesFiles: [CA_CERT_PATH], + }, + }, + ]; + const customHostSettings = customizeLocalHostTls + ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] + : []; + return { testFiles: [require.resolve(`../${name}/tests/`)], servers, @@ -119,7 +152,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, ...actionsProxyUrl, - + ...customHostSettings, '--xpack.eventLog.logEntries=true', `--xpack.actions.preconfiguredAlertHistoryEsIndex=${preconfiguredAlertHistoryEsIndex}`, `--xpack.actions.preconfigured=${JSON.stringify({ @@ -162,6 +195,34 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) encrypted: 'this-is-also-ignored-and-also-required', }, }, + 'custom.tls.noCustom': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.noCustom}`, + config: { + url: tlsWebhookServers.noCustom, + }, + }, + 'custom.tls.rejectUnauthorizedFalse': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedFalse}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedFalse, + }, + }, + 'custom.tls.rejectUnauthorizedTrue': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedTrue}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedTrue, + }, + }, + 'custom.tls.caFile': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.caFile}`, + config: { + url: tlsWebhookServers.caFile, + }, + }, })}`, ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), ...plugins.map( diff --git a/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts new file mode 100644 index 000000000000..026cf21cb592 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts @@ -0,0 +1,78 @@ +/* + * 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 fs from 'fs'; +import https from 'https'; +import getPort from 'get-port'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; + +interface TlsWebhookURLs { + noCustom: string; + rejectUnauthorizedFalse: string; + rejectUnauthorizedTrue: string; + caFile: string; +} + +const ServerCert = fs.readFileSync(KBN_CERT_PATH, 'utf8'); +const ServerKey = fs.readFileSync(KBN_KEY_PATH, 'utf8'); + +export async function getTlsWebhookServerUrls( + portRangeStart: number, + portRangeEnd: number +): Promise { + let port: number; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const noCustom = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedFalse = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedTrue = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const caFile = `https://localhost:${port}`; + + return { + noCustom, + rejectUnauthorizedFalse, + rejectUnauthorizedTrue, + caFile, + }; +} + +export async function createTlsWebhookServer(port: string): Promise { + const httpsOptions = { + cert: ServerCert, + key: ServerKey, + }; + + const server = https.createServer(httpsOptions, async (req, res) => { + if (req.method === 'POST' || req.method === 'PUT') { + const allRead = new Promise((resolve) => { + req.on('data', (chunk) => {}); + req.on('end', () => resolve(null)); + }); + await allRead; + } + + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + const listening = new Promise((resolve) => { + server.listen(port, () => { + resolve(null); + }); + }); + await listening; + + // let node exit even if we don't close this server + server.unref(); + + return server; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 059ef59fc614..9a3a78342c5a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -60,7 +60,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -168,7 +174,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -252,7 +264,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'global_read at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-es-index-action', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 49d5f52869b8..3b3a15b6d62e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,5 +13,6 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, rejectUnauthorized: false, + customizeLocalHostTls: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index 8ef573a3ae2c..4af33136cd42 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -15,6 +15,7 @@ import { getWebhookServer, getHttpsWebhookServer, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { createTlsWebhookServer } from '../../../../common/lib/get_tls_webhook_servers'; // eslint-disable-next-line import/no-default-export export default function webhookTest({ getService }: FtrProviderContext) { @@ -47,6 +48,19 @@ export default function webhookTest({ getService }: FtrProviderContext) { return createdAction.id; } + async function getPortOfConnector(connectorId: string): Promise { + const response = await supertest.get(`/api/actions/connectors`).expect(200); + const connector = response.body.find((conn: { id: string }) => conn.id === connectorId); + if (connector === undefined) { + throw new Error(`unable to find connector with id ${connectorId}`); + } + + // server URL is the connector name + const url = connector.name; + const parsedUrl = new URL(url); + return parsedUrl.port; + } + describe('webhook action', () => { describe('with http endpoint', () => { let webhookSimulatorURL: string = ''; @@ -108,5 +122,80 @@ export default function webhookTest({ getService }: FtrProviderContext) { webhookServer.close(); }); }); + + describe('tls customization', () => { + it('should handle the xpack.actions.rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.noCustom'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: true', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('error'); + expect(body.service_message.indexOf('certificate')).to.be.greaterThan(0); + }); + + it('should handle the customized ca file', async () => { + const connectorId = 'custom.tls.caFile'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 28abd0b79c57..e7f500f2771e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -35,7 +36,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -102,7 +113,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -159,7 +180,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index',