[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.
This commit is contained in:
Patrick Mueller 2021-04-28 15:26:47 -04:00 committed by GitHub
parent 02ac599569
commit b31f4a1a97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1840 additions and 17 deletions

View file

@ -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 <count>[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: +
+
`<count>[ms,s,m,h,d,w,M,Y]` +
+
For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`.
|===

View file

@ -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 <<task-manager-health-monitoring>>.
[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 <<action-settings,Action settings>>.

View file

@ -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

View file

@ -21,6 +21,7 @@ const createActionsConfigMock = () => {
maxContentLength: 1000000,
timeout: 360000,
}),
getCustomHostSettings: jest.fn().mockReturnValue(undefined),
};
return mocked;
};

View file

@ -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<Logger>;
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);
});
});

View file

@ -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),
};
}

View file

@ -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],

View file

@ -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<Logger>;
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<CreateServerResult> {
let server: http.Server | https.Server;
const readySignal = createReadySignal<CreateServerResult>();
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<ActionsConfig> = {}): ActionsConfigurationUtilities {
return getActionsConfigurationUtilities({
...BaseActionsConfig,
...config,
});
}
function localUrlFromPort(useHttps: boolean, port: number, host: string): string {
return `${useHttps ? 'https' : 'http'}://${host}:${port}`;
}

View file

@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
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();
});
});

View file

@ -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 };
}

View file

@ -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,

View file

@ -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<string, unknown> = {};
// 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<string, any> = {};
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<string, unknown> = {};
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);

View file

@ -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],

View file

@ -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],

View file

@ -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<string, unknown> = {
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

View file

@ -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<typeof customHostSettingsSchema>;
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' }),

View file

@ -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",
]
`);
});
});
});

View file

@ -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<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type ActionsConfigWriteable = DeepWriteable<ActionsConfig>;
type CustomHostSettingsWriteable = DeepWriteable<CustomHostSettings>;
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<string>();
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;
}

View file

@ -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<PluginSetupContract, PluginStartCon
constructor(initContext: PluginInitializerContext) {
this.logger = initContext.logger.get('actions');
this.actionsConfig = getValidatedConfig(this.logger, initContext.config.get<ActionsConfig>());
this.actionsConfig = getValidatedConfig(
this.logger,
resolveCustomHosts(this.logger, initContext.config.get<ActionsConfig>())
);
this.telemetryLogger = initContext.logger.get('usage');
this.preconfiguredActions = [];
this.kibanaIndexConfig = initContext.config.legacy.get();

View file

@ -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(

View file

@ -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<TlsWebhookURLs> {
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<https.Server> {
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;
}

View file

@ -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,

View file

@ -13,5 +13,6 @@ export default createTestConfig('spaces_only', {
license: 'trial',
enableActionsProxy: false,
rejectUnauthorized: false,
customizeLocalHostTls: true,
preconfiguredAlertHistoryEsIndex: true,
});

View file

@ -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<string> {
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');
});
});
});
}

View file

@ -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',