diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 3645499d5f9ffe..08cbee8851b98f 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -59,6 +59,12 @@ You can configure the following settings in the `kibana.yml` file. | `xpack.actions.proxyUrl` {ess-icon} | Specifies the proxy URL to use, if using a proxy for actions. By default, no proxy is used. +| `xpack.actions.proxyBypassHosts` {ess-icon} + | Specifies hostnames which should not use the proxy, if using a proxy for actions. The value is an array of hostnames as strings. By default, all hosts will use the proxy, but if an action's hostname is in this list, the proxy will not be used. The settings `xpack.actions.proxyBypassHosts` and `xpack.actions.proxyOnlyHosts` cannot be used at the same time. + +| `xpack.actions.proxyOnlyHosts` {ess-icon} + | Specifies hostnames which should only use the proxy, if using a proxy for actions. The value is an array of hostnames as strings. By default, no hosts will use the proxy, but if an action's hostname is in this list, the proxy will be used. The settings `xpack.actions.proxyBypassHosts` and `xpack.actions.proxyOnlyHosts` cannot be used at the same time. + | `xpack.actions.proxyHeaders` {ess-icon} | Specifies HTTP headers for the proxy, if using a proxy for actions. Defaults to {}. 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 503119f95f0613..2c8cc185323aa3 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 @@ -163,6 +163,8 @@ kibana_vars=( xpack.actions.proxyHeaders xpack.actions.proxyRejectUnauthorizedCertificates xpack.actions.proxyUrl + xpack.actions.proxyBypassHosts + xpack.actions.proxyOnlyHosts xpack.actions.rejectUnauthorized xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index a333d86b27129f..92d3b4f29d9675 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -406,6 +406,8 @@ describe('create()', () => { preconfigured: {}, proxyRejectUnauthorizedCertificates: true, rejectUnauthorized: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index cae6777a82441f..36899f7661ba40 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -253,3 +253,82 @@ describe('ensureActionTypeEnabled', () => { expect(getActionsConfigurationUtilities(config).ensureActionTypeEnabled('foo')).toBeUndefined(); }); }); + +describe('getProxySettings', () => { + test('returns undefined when no proxy URL set', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + proxyHeaders: { someHeaderName: 'some header value' }, + proxyBypassHosts: ['avoid-proxy.co'], + }; + + const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); + expect(proxySettings).toBeUndefined(); + }); + + test('returns proxy url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + }; + const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); + expect(proxySettings?.proxyUrl).toBe(config.proxyUrl); + }); + + test('returns proxyRejectUnauthorizedCertificates', () => { + const configTrue: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + proxyRejectUnauthorizedCertificates: true, + }; + let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); + expect(proxySettings?.proxyRejectUnauthorizedCertificates).toBe(true); + + const configFalse: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + proxyRejectUnauthorizedCertificates: false, + }; + proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); + expect(proxySettings?.proxyRejectUnauthorizedCertificates).toBe(false); + }); + + test('returns proxy headers', () => { + const proxyHeaders = { + someHeaderName: 'some header value', + someOtherHeader: 'some other header', + }; + const config: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + proxyHeaders, + }; + + const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); + expect(proxySettings?.proxyHeaders).toEqual(config.proxyHeaders); + }); + + test('returns proxy bypass hosts', () => { + const proxyBypassHosts = ['proxy-bypass-1.elastic.co', 'proxy-bypass-2.elastic.co']; + const config: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + proxyBypassHosts, + }; + + const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); + expect(proxySettings?.proxyBypassHosts).toEqual(new Set(proxyBypassHosts)); + }); + + test('returns proxy only hosts', () => { + const proxyOnlyHosts = ['proxy-only-1.elastic.co', 'proxy-only-2.elastic.co']; + const config: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + proxyOnlyHosts, + }; + + const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); + expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 2787f8f9711015..b35a4a0d7b6c5e 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,17 +11,11 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings } from './types'; -export enum AllowedHosts { - Any = '*', -} - -export enum EnabledActionTypes { - Any = '*', -} +export { AllowedHosts, EnabledActionTypes } from './config'; enum AllowListingField { URL = 'url', @@ -93,11 +87,18 @@ function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySet return { proxyUrl: config.proxyUrl, + proxyBypassHosts: arrayAsSet(config.proxyBypassHosts), + proxyOnlyHosts: arrayAsSet(config.proxyOnlyHosts), proxyHeaders: config.proxyHeaders, proxyRejectUnauthorizedCertificates: config.proxyRejectUnauthorizedCertificates, }; } +function arrayAsSet(arr: T[] | undefined): Set | undefined { + if (!arr) return; + return new Set(arr); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index 6a67f4f6752c25..a932b38ede2bb2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -7,12 +7,16 @@ import axios from 'axios'; import { Agent as HttpsAgent } from 'https'; +import HttpProxyAgent from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { getCustomAgents } from './get_custom_agents'; +const TestUrl = 'https://elastic.co/foo/bar/baz'; + const logger = loggingSystemMock.create().get() as jest.Mocked; const configurationUtilities = actionsConfigMock.create(); jest.mock('axios'); @@ -66,17 +70,19 @@ describe('request', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyRejectUnauthorizedCertificates: true, proxyUrl: 'https://localhost:1212', + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); - const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, TestUrl); const res = await request({ axios, - url: 'http://testProxy', + url: TestUrl, logger, configurationUtilities, }); - expect(axiosMock).toHaveBeenCalledWith('http://testProxy', { + expect(axiosMock).toHaveBeenCalledWith(TestUrl, { method: 'get', data: {}, httpAgent, @@ -94,6 +100,8 @@ describe('request', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope:', proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); const res = await request({ axios, @@ -116,6 +124,90 @@ describe('request', () => { }); }); + test('it bypasses with proxyBypassHosts when expected', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyRejectUnauthorizedCertificates: true, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['elastic.co']), + proxyOnlyHosts: undefined, + }); + + await request({ + axios, + url: TestUrl, + logger, + configurationUtilities, + }); + + expect(axiosMock.mock.calls.length).toBe(1); + const { httpAgent, httpsAgent } = axiosMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + + test('it does not bypass with proxyBypassHosts when expected', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyRejectUnauthorizedCertificates: true, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['not-elastic.co']), + proxyOnlyHosts: undefined, + }); + + await request({ + axios, + url: TestUrl, + logger, + configurationUtilities, + }); + + expect(axiosMock.mock.calls.length).toBe(1); + const { httpAgent, httpsAgent } = axiosMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + test('it proxies with proxyOnlyHosts when expected', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyRejectUnauthorizedCertificates: true, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['elastic.co']), + }); + + await request({ + axios, + url: TestUrl, + logger, + configurationUtilities, + }); + + expect(axiosMock.mock.calls.length).toBe(1); + const { httpAgent, httpsAgent } = axiosMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + test('it does not proxy with proxyOnlyHosts when expected', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyRejectUnauthorizedCertificates: true, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-elastic.co']), + }); + + await request({ + axios, + url: TestUrl, + logger, + configurationUtilities, + }); + + expect(axiosMock.mock.calls.length).toBe(1); + const { httpAgent, httpsAgent } = axiosMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + test('it fetch correctly', async () => { const res = await request({ axios, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index f86f3b86c506af..edce3690961426 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -30,7 +30,7 @@ export const request = async ({ validateStatus?: (status: number) => boolean; auth?: AxiosBasicCredentials; }): Promise => { - const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, url); return await axios(url, { ...rest, 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 340ac0f6dda3a2..f6d1be9bffc6b6 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 @@ -14,6 +14,10 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked; +const targetHost = 'elastic.co'; +const targetUrl = `https://${targetHost}/foo/bar/baz`; +const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; + describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); @@ -21,8 +25,10 @@ describe('getCustomAgents', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); - const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); }); @@ -31,15 +37,73 @@ describe('getCustomAgents', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope: not a valid URL', proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); - const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); expect(httpAgent).toBe(undefined); expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); test('return default agents for undefined proxy options', () => { - const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); expect(httpAgent).toBe(undefined); expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); + + test('returns non-proxy agents for matching proxyBypassHosts', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set([targetHost]), + proxyOnlyHosts: undefined, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + }); + + test('returns proxy agents for non-matching proxyBypassHosts', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set([targetHost]), + proxyOnlyHosts: undefined, + }); + const { httpAgent, httpsAgent } = getCustomAgents( + configurationUtilities, + logger, + nonMatchingUrl + ); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + }); + + test('returns proxy agents for matching proxyOnlyHosts', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set([targetHost]), + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + }); + + test('returns non-proxy agents for non-matching proxyOnlyHosts', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set([targetHost]), + }); + const { httpAgent, httpsAgent } = getCustomAgents( + configurationUtilities, + logger, + nonMatchingUrl + ); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsProxyAgent).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 92ababf830aa73..ff2d005f4d8415 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 @@ -19,7 +19,8 @@ interface GetCustomAgentsResponse { export function getCustomAgents( configurationUtilities: ActionsConfigurationUtilities, - logger: Logger + logger: Logger, + url: string ): GetCustomAgentsResponse { const proxySettings = configurationUtilities.getProxySettings(); const defaultAgents = { @@ -33,6 +34,28 @@ export function getCustomAgents( return defaultAgents; } + let targetUrl: URL; + try { + targetUrl = new URL(url); + } catch (err) { + logger.warn(`error determining proxy state for invalid url "${url}", using default agents`); + return defaultAgents; + } + + // filter out hostnames in the proxy bypass or only lists + const { hostname } = targetUrl; + + if (proxySettings.proxyBypassHosts) { + if (proxySettings.proxyBypassHosts.has(hostname)) { + return defaultAgents; + } + } + + if (proxySettings.proxyOnlyHosts) { + if (!proxySettings.proxyOnlyHosts.has(hostname)) { + return defaultAgents; + } + } logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { 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 cc3f03f50c36fe..4b45c6d787cd6b 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 @@ -76,6 +76,8 @@ describe('send_email module', () => { { proxyUrl: 'https://example.com', proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, } ); @@ -222,6 +224,138 @@ describe('send_email module', () => { await expect(sendEmail(mockLogger, sendEmailOptions)).rejects.toThrow('wops'); }); + + test('it bypasses with proxyBypassHosts when expected', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set(['example.com']), + proxyOnlyHosts: undefined, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "secure": false, + "tls": Object { + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it does not bypass with proxyBypassHosts when expected', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set(['not-example.com']), + proxyOnlyHosts: undefined, + } + ); + + 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", + "secure": false, + "tls": Object { + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it proxies with proxyOnlyHosts when expected', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['example.com']), + } + ); + + 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", + "secure": false, + "tls": Object { + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it does not proxy with proxyOnlyHosts when expected', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-example.com']), + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "secure": false, + "tls": Object { + "rejectUnauthorized": false, + }, + }, + ] + `); + }); }); function getSendEmailOptions( 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 d4905015f7663b..c0a254967b4fed 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 @@ -63,6 +63,17 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom }; } + let useProxy = !!proxySettings; + + if (host) { + if (proxySettings?.proxyBypassHosts && proxySettings?.proxyBypassHosts?.has(host)) { + useProxy = false; + } + if (proxySettings?.proxyOnlyHosts && !proxySettings?.proxyOnlyHosts?.has(host)) { + useProxy = false; + } + } + if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; delete transportConfig.auth; @@ -73,7 +84,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.port = port; transportConfig.secure = !!secure; - if (proxySettings) { + if (proxySettings && useProxy) { transportConfig.tls = { // do not fail on invalid certs if value is false rejectUnauthorized: proxySettings?.proxyRejectUnauthorizedCertificates, diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 6479e29b5a76ff..76612696e8e583 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -195,6 +195,8 @@ describe('execute()', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, }); const actionTypeProxy = getActionType({ logger: mockedLogger, @@ -212,6 +214,106 @@ describe('execute()', () => { ); }); + test('ensure proxy bypass will bypass when expected', async () => { + mockedLogger.debug.mockReset(); + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set(['example.com']), + proxyOnlyHosts: undefined, + }); + const actionTypeProxy = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + await actionTypeProxy.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(mockedLogger.debug).not.toHaveBeenCalledWith( + 'IncomingWebhook was called with proxyUrl https://someproxyhost' + ); + }); + + test('ensure proxy bypass will not bypass when expected', async () => { + mockedLogger.debug.mockReset(); + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: new Set(['not-example.com']), + proxyOnlyHosts: undefined, + }); + const actionTypeProxy = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + await actionTypeProxy.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'IncomingWebhook was called with proxyUrl https://someproxyhost' + ); + }); + + test('ensure proxy only will proxy when expected', async () => { + mockedLogger.debug.mockReset(); + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['example.com']), + }); + const actionTypeProxy = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + await actionTypeProxy.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'IncomingWebhook was called with proxyUrl https://someproxyhost' + ); + }); + + test('ensure proxy only will not proxy when expected', async () => { + mockedLogger.debug.mockReset(); + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-example.com']), + }); + const actionTypeProxy = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + await actionTypeProxy.executor({ + actionId: 'some-id', + services, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(mockedLogger.debug).not.toHaveBeenCalledWith( + 'IncomingWebhook was called with proxyUrl https://someproxyhost' + ); + }); + test('renders parameter templates as expected', async () => { expect(actionType.renderParameterTemplates).toBeTruthy(); const paramsWithTemplates = { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index a6173229e3267a..d0fb4a8c4b9359 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -7,6 +7,8 @@ import { URL } from 'url'; import { curry } from 'lodash'; +import HttpProxyAgent from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook'; @@ -131,13 +133,15 @@ async function slackExecutor( const { message } = params; const proxySettings = configurationUtilities.getProxySettings(); - const customAgents = getCustomAgents(configurationUtilities, logger); + const customAgents = getCustomAgents(configurationUtilities, logger, webhookUrl); const agent = webhookUrl.toLowerCase().startsWith('https') ? customAgents.httpsAgent : customAgents.httpAgent; if (proxySettings) { - logger.debug(`IncomingWebhook was called with proxyUrl ${proxySettings.proxyUrl}`); + if (agent instanceof HttpProxyAgent || agent instanceof HttpsProxyAgent) { + logger.debug(`IncomingWebhook was called with proxyUrl ${proxySettings.proxyUrl}`); + } } try { diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index c90a5b2fb9768c..0d270512d1deea 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -5,9 +5,17 @@ * 2.0. */ -import { configSchema } from './config'; +import { configSchema, ActionsConfig, getValidatedConfig } from './config'; +import { Logger } from '../../../..//src/core/server'; +import { loggingSystemMock } from '../../../..//src/core/server/mocks'; + +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; describe('config validation', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + test('action defaults', () => { const config: Record = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` @@ -84,6 +92,56 @@ describe('config validation', () => { `"[preconfigured]: invalid preconfigured action id \\"__proto__\\""` ); }); + + test('validates proxyBypassHosts and proxyOnlyHosts', () => { + const bypassHosts = ['bypass.elastic.co']; + const onlyHosts = ['only.elastic.co']; + let validated: ActionsConfig; + + validated = configSchema.validate({}); + expect(validated.proxyBypassHosts).toEqual(undefined); + expect(validated.proxyOnlyHosts).toEqual(undefined); + + validated = configSchema.validate({ + proxyBypassHosts: bypassHosts, + }); + expect(validated.proxyBypassHosts).toEqual(bypassHosts); + expect(validated.proxyOnlyHosts).toEqual(undefined); + + validated = configSchema.validate({ + proxyOnlyHosts: onlyHosts, + }); + expect(validated.proxyBypassHosts).toEqual(undefined); + expect(validated.proxyOnlyHosts).toEqual(onlyHosts); + }); + + test('validates proxyBypassHosts and proxyOnlyHosts used at the same time', () => { + const bypassHosts = ['bypass.elastic.co']; + const onlyHosts = ['only.elastic.co']; + const config: Record = { + proxyBypassHosts: bypassHosts, + proxyOnlyHosts: onlyHosts, + }; + + let validated: ActionsConfig; + + // the config schema validation validates with both set + validated = configSchema.validate(config); + expect(validated.proxyBypassHosts).toEqual(bypassHosts); + expect(validated.proxyOnlyHosts).toEqual(onlyHosts); + + // getValidatedConfig will warn and set onlyHosts to undefined with both set + validated = getValidatedConfig(mockLogger, validated); + expect(validated.proxyBypassHosts).toEqual(bypassHosts); + expect(validated.proxyOnlyHosts).toEqual(undefined); + expect(mockLogger.warn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "The confgurations xpack.actions.proxyBypassHosts and xpack.actions.proxyOnlyHosts can not be used at the same time. The configuration xpack.actions.proxyOnlyHosts will be ignored.", + ], + ] + `); + }); }); // 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 b4f29b752957fb..450f03308ab0bb 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -6,7 +6,15 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { AllowedHosts, EnabledActionTypes } from './actions_config'; +import { Logger } from '../../../../src/core/server'; + +export enum AllowedHosts { + Any = '*', +} + +export enum EnabledActionTypes { + Any = '*', +} const preconfiguredActionSchema = schema.object({ name: schema.string({ minLength: 1 }), @@ -36,11 +44,32 @@ export const configSchema = schema.object({ proxyUrl: schema.maybe(schema.string()), proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())), proxyRejectUnauthorizedCertificates: schema.boolean({ defaultValue: true }), + proxyBypassHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), + proxyOnlyHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), rejectUnauthorized: schema.boolean({ defaultValue: true }), }); export type ActionsConfig = TypeOf; +// It would be nicer to add the proxyBypassHosts / proxyOnlyHosts restriction on +// simultaneous usage in the config validator directly, but there's no good way to express +// this relationship in the cloud config constraints, so we're doing it "live". +export function getValidatedConfig(logger: Logger, originalConfig: ActionsConfig): ActionsConfig { + const proxyBypassHosts = originalConfig.proxyBypassHosts; + const proxyOnlyHosts = originalConfig.proxyOnlyHosts; + + if (proxyBypassHosts && proxyOnlyHosts) { + logger.warn( + 'The confgurations xpack.actions.proxyBypassHosts and xpack.actions.proxyOnlyHosts can not be used at the same time. The configuration xpack.actions.proxyOnlyHosts will be ignored.' + ); + const tmp: Record = originalConfig; + delete tmp.proxyOnlyHosts; + return tmp as ActionsConfig; + } + + return originalConfig; +} + const invalidActionIds = new Set(['', '__proto__', 'constructor']); function validatePreconfigured(preconfigured: Record): string | undefined { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 5ec9241533b3c3..bfe3b0a09ff2e0 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -30,7 +30,7 @@ import { SpacesPluginStart } from '../../spaces/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; -import { ActionsConfig } from './config'; +import { ActionsConfig, getValidatedConfig } from './config'; import { ActionExecutor, TaskRunnerFactory, LicenseState, ILicenseState } from './lib'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; @@ -141,8 +141,8 @@ export class ActionsPlugin implements Plugin(); this.logger = initContext.logger.get('actions'); + this.actionsConfig = getValidatedConfig(this.logger, initContext.config.get()); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 4e3916f5d6e231..6830f013ade5f6 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -133,6 +133,8 @@ export interface ActionTaskExecutorParams { export interface ProxySettings { proxyUrl: string; + proxyBypassHosts: Set | undefined; + proxyOnlyHosts: Set | undefined; proxyHeaders?: Record; proxyRejectUnauthorizedCertificates: boolean; } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 560ff6c0b317fe..beb639eb46334f 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -68,12 +68,24 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) const proxyPort = process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6300) })); + + // If testing with proxy, also test proxyOnlyHosts for this proxy; + // all the actions are assumed to be acccessing localhost anyway. + // If not testing with proxy, set a bogus proxy up, and set the bypass + // flag for all our localhost actions to bypass it. Currently, + // security_and_spaces uses enableActionsProxy: true, and spaces_only + // uses enableActionsProxy: false. + const proxyHosts = ['localhost', 'some.non.existent.com']; const actionsProxyUrl = options.enableActionsProxy ? [ `--xpack.actions.proxyUrl=http://localhost:${proxyPort}`, + `--xpack.actions.proxyOnlyHosts=${JSON.stringify(proxyHosts)}`, '--xpack.actions.proxyRejectUnauthorizedCertificates=false', ] - : []; + : [ + `--xpack.actions.proxyUrl=http://elastic.co`, + `--xpack.actions.proxyBypassHosts=${JSON.stringify(proxyHosts)}`, + ]; return { testFiles: [require.resolve(`../${name}/tests/`)],