[actions] adds proxyBypassHosts and proxyOnlyHosts Kibana config keys (#95365)

resolves https://github.com/elastic/kibana/issues/92949

This PR adds two new Kibana config keys to further customize when the proxy
is used when making HTTP requests.  Prior to this PR, if a proxy was set
via the `xpack.actions.proxyUrl` config key, all requests would be
proxied.

Now, there's a further refinement in that hostnames can be added
to the `xpack.actions.proxyBypassHosts` and `xpack.actions.proxyOnlyHosts`
config keys.  Only one of these config keys can be used at a time.

If the target URL hostname of the HTTP request is listed in the
`proxyBypassHosts` list, the proxy won't be used.

If the target URL hostname of the HTTP request is **NOT** listed in the
`proxyOnlyHosts` list, the proxy won't be used.

Depending on the customer's environment, it may be easier to list the hosts to
bypass, or easier to list the hosts that should only be proxied, so they can
choose either method.
This commit is contained in:
Patrick Mueller 2021-04-07 15:20:47 -04:00 committed by GitHub
parent 21f38afd27
commit ad06d16beb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 645 additions and 24 deletions

View file

@ -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 {}.

View file

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

View file

@ -406,6 +406,8 @@ describe('create()', () => {
preconfigured: {},
proxyRejectUnauthorizedCertificates: true,
rejectUnauthorized: true,
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
});
const localActionTypeRegistryParams = {

View file

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

View file

@ -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<T>(arr: T[] | undefined): Set<T> | undefined {
if (!arr) return;
return new Set(arr);
}
export function getActionsConfigurationUtilities(
config: ActionsConfig
): ActionsConfigurationUtilities {

View file

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

View file

@ -30,7 +30,7 @@ export const request = async <T = unknown>({
validateStatus?: (status: number) => boolean;
auth?: AxiosBasicCredentials;
}): Promise<AxiosResponse> => {
const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger);
const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, url);
return await axios(url, {
...rest,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Logger>;
describe('config validation', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('action defaults', () => {
const config: Record<string, unknown> = {};
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<string, unknown> = {
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

View file

@ -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<typeof configSchema>;
// 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<string, unknown> = originalConfig;
delete tmp.proxyOnlyHosts;
return tmp as ActionsConfig;
}
return originalConfig;
}
const invalidActionIds = new Set(['', '__proto__', 'constructor']);
function validatePreconfigured(preconfigured: Record<string, unknown>): string | undefined {

View file

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

View file

@ -133,6 +133,8 @@ export interface ActionTaskExecutorParams {
export interface ProxySettings {
proxyUrl: string;
proxyBypassHosts: Set<string> | undefined;
proxyOnlyHosts: Set<string> | undefined;
proxyHeaders?: Record<string, string>;
proxyRejectUnauthorizedCertificates: boolean;
}

View file

@ -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/`)],