From f43680edd5c6db4385e08a35c2d835772447e4ce Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 16 Aug 2022 10:00:46 -0400 Subject: [PATCH] Adds new proxy tests and manual proxy tester (#138071) The new proxy tests added can test a variety of different proxy and target server configurations, however many of those tests are broken with our current proxy agents. Hopefully to be fixed by replacing with hpagent instead. In the meantime, we wanted to get the basic test framework in as well. In addition to tests, the stand-alone forward proxy has been enhanced to use a better proxy server, `proxy`. The existing proxy server `http-proxy` does not support HTTPS out of the box, and so any HTTPS testing with it is going to be a little sketchy. Using the stand-alone forward proxy, I was able to post to Slack through http/https proxies with and without auth, with proxyRequestUnauthorized set to false. Which shows the existing proxy agents do work in _some_ environments. --- docs/settings/alert-action-settings.asciidoc | 16 + package.json | 1 + .../actions/jest.integration.config.js | 12 + .../lib/axios_utils_connection.test.ts | 284 -------- .../axios_utils_connection.test.ts | 595 +++++++++++++++++ .../axios_utils_proxy.test.ts | 607 ++++++++++++++++++ .../server/manual_tests/forward_proxy.js | 193 +++--- yarn.lock | 57 +- 8 files changed, 1397 insertions(+), 368 deletions(-) create mode 100644 x-pack/plugins/actions/jest.integration.config.js delete mode 100644 x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts create mode 100644 x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts create mode 100644 x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 95003a08b7b095..b937dad3856070 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -143,6 +143,22 @@ Specifies preconfigured connector IDs and configs. Default: {}. `xpack.actions.proxyUrl` {ess-icon}:: Specifies the proxy URL to use, if using a proxy for actions. By default, no proxy is used. ++ +Proxies may be used to proxy http or https requests through a proxy using the http or https protocol. Kibana only uses proxies in "CONNECT" mode (sometimes referred to as "tunneling" TCP mode, compared to HTTP mode). That is, Kibana will always make requests through a proxy using the HTTP `CONNECT` method. ++ +If your proxy is using the https protocol (vs the http protocol), the setting `xpack.actions.ssl.proxyVerificationMode: none` will likely be needed, unless your proxy's certificates are signed using a publicly available certificate authority. ++ +There is currently no support for using basic authentication with a proxy (authentication for the proxy itself, not the URL being requested through the proxy). ++ +To help diagnose problems using a proxy, you can use the `curl` command with options to use your proxy, and log debug information, with the following command, replacing the proxy and target URLs as appropriate. This will force the request to be made to the +proxy in tunneling mode, and display some of the interaction between the client and the proxy. ++ +[source,sh] +-- +curl --verbose --proxytunnel --proxy http://localhost:8080 http://example.com +-- ++ + `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. diff --git a/package.json b/package.json index c080f0f96612da..2285e317ad9234 100644 --- a/package.json +++ b/package.json @@ -1242,6 +1242,7 @@ "postcss-scss": "^4.0.4", "prettier": "^2.7.1", "pretty-format": "^27.5.1", + "proxy": "^1.0.2", "q": "^1.5.1", "raw-loader": "^3.1.0", "react-test-renderer": "^16.14.0", diff --git a/x-pack/plugins/actions/jest.integration.config.js b/x-pack/plugins/actions/jest.integration.config.js new file mode 100644 index 00000000000000..41bd46b12005ef --- /dev/null +++ b/x-pack/plugins/actions/jest.integration.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test/jest_integration_node', + rootDir: '../../..', + roots: ['/x-pack/plugins/actions'], +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts deleted file mode 100644 index d5daa33cbb47c0..00000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* - * 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 '@kbn/core/server'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { createReadySignal } from '@kbn/event-log-plugin/server/lib/ready_signal'; -import { ActionsConfig } from '../../config'; -import { - ActionsConfigurationUtilities, - getActionsConfigurationUtilities, -} from '../../actions_config'; - -const logger = loggingSystemMock.create().get() as jest.Mocked; - -const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; - -const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); -const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); -const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); - -const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); -const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); -const CA = fsReadFileSync(CA_FILE, 'utf8'); - -describe('axios connections', () => { - let testServer: http.Server | https.Server; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let savedAxiosDefaultsAdapter: any; - - beforeAll(() => { - // needed to prevent the dreaded Error: Cross origin http://localhost forbidden - // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 - savedAxiosDefaultsAdapter = axios.defaults.adapter; - axios.defaults.adapter = require('axios/lib/adapters/http'); - }); - - afterAll(() => { - axios.defaults.adapter = savedAxiosDefaultsAdapter; - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - afterEach(() => { - testServer.close(); - }); - - describe('http', () => { - test('it works', async () => { - const { url, server } = await createServer(); - testServer = server; - - const configurationUtilities = getACUfromConfig(); - const res = await request({ axios, url, logger, configurationUtilities }); - expect(res.status).toBe(200); - }); - }); - - describe('https', () => { - test('it fails with self-signed cert and no overrides', async () => { - const { url, server } = await createServer(true); - testServer = server; - - const configurationUtilities = getACUfromConfig(); - const fn = async () => await request({ axios, url, logger, configurationUtilities }); - await expect(fn()).rejects.toThrow('certificate'); - }); - - test('it works with verificationMode "none" config', async () => { - const { url, server } = await createServer(true); - testServer = server; - - const configurationUtilities = getACUfromConfig({ - ssl: { - verificationMode: 'none', - }, - }); - const res = await request({ axios, url, logger, configurationUtilities }); - expect(res.status).toBe(200); - }); - - test('it works with verificationMode "none" for custom host config', async () => { - const { url, server } = await createServer(true); - testServer = server; - - const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, ssl: { verificationMode: 'none' } }], - }); - 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, ssl: { 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, ssl: { 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 verificationMode "none"', async () => { - const { url, server } = await createServer(true); - testServer = server; - - const configurationUtilities = getACUfromConfig({ - customHostSettings: [ - { - url, - ssl: { - certificateAuthoritiesData: CA, - verificationMode: 'none', - }, - }, - ], - }); - const res = await request({ axios, url, logger, configurationUtilities }); - expect(res.status).toBe(200); - }); - - test('it works with incorrect ca in custom host config but verificationMode config "full"', async () => { - const { url, server } = await createServer(true); - testServer = server; - - const configurationUtilities = getACUfromConfig({ - ssl: { - verificationMode: 'none', - }, - customHostSettings: [ - { - url, - ssl: { - 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, ssl: { verificationMode: 'none' } }], - }); - 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, ssl: { 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, ssl: { certificateAuthoritiesData: ca } }], - }); - const fn = async () => await request({ axios, url, logger, configurationUtilities }); - await expect(fn()).rejects.toThrow('certificate'); - }); - }); -}); - -interface CreateServerResult { - url: string; - server: http.Server | https.Server; -} - -async function createServer(useHttps: boolean = false): Promise { - let server: http.Server | https.Server; - const readySignal = createReadySignal(); - - if (!useHttps) { - server = http.createServer((req, res) => { - res.writeHead(200); - res.end('http: just testing that a connection could be made'); - }); - } else { - const httpsOptions = { - cert: KIBANA_CRT, - key: KIBANA_KEY, - }; - server = https.createServer(httpsOptions, (req, res) => { - res.writeHead(200); - res.end('https: just testing that a connection could be made'); - }); - } - - server.listen(() => { - const addressInfo = server.address(); - if (addressInfo == null || typeof addressInfo === 'string') { - server.close(); - throw new Error('error getting address of server, closing'); - } - - const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); - readySignal.signal({ server, url }); - }); - - // let the node process stop if for some reason this server isn't closed - server.unref(); - - return readySignal.wait(); -} - -const BaseActionsConfig: ActionsConfig = { - allowedHosts: ['*'], - enabledActionTypes: ['*'], - preconfiguredAlertHistoryEsIndex: false, - preconfigured: {}, - proxyUrl: undefined, - proxyHeaders: undefined, - proxyRejectUnauthorizedCertificates: true, - ssl: { - proxyVerificationMode: 'full', - verificationMode: 'full', - }, - proxyBypassHosts: undefined, - proxyOnlyHosts: undefined, - rejectUnauthorized: true, - maxResponseContentLength: ByteSizeValue.parse('1mb'), - responseTimeout: momentDuration(1000 * 30), - customHostSettings: undefined, - cleanupFailedExecutionsTask: { - enabled: true, - cleanupInterval: schema.duration().validate('5m'), - idleInterval: schema.duration().validate('1h'), - pageSize: 100, - }, -}; - -function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { - return getActionsConfigurationUtilities({ - ...BaseActionsConfig, - ...config, - }); -} - -function localUrlFromPort(useHttps: boolean, port: number, host: string): string { - return `${useHttps ? 'https' : 'http'}://${host}:${port}`; -} diff --git a/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts new file mode 100644 index 00000000000000..2b5a137ed0bccc --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/axios_utils_connection.test.ts @@ -0,0 +1,595 @@ +/* + * 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. + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const proxySetup = require('proxy'); + +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 getPort from 'get-port'; + +import { request } from '../builtin_action_types/lib/axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createReadySignal } from '@kbn/event-log-plugin/server/lib/ready_signal'; +import { ActionsConfig } from '../config'; +import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from '../actions_config'; +import { resolveCustomHosts } from '../lib/custom_host_settings'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +const Auth = 'elastic:changeme'; +const AuthB64 = Buffer.from(Auth).toString('base64'); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const AxiosDefaultsAadapter = require('axios/lib/adapters/http'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeEach(() => { + // 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 = AxiosDefaultsAadapter; + }); + + afterEach(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer?.close(); + testServer = null; + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer({ useHttps: false }); + 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({ useHttps: 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 verificationMode "none" config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + ssl: { + verificationMode: 'none', + }, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with verificationMode "none" for custom host config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { verificationMode: 'none' } }], + }); + 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({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { 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({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { 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 verificationMode "none"', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + ssl: { + certificateAuthoritiesData: CA, + verificationMode: 'none', + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but verificationMode config "full"', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + ssl: { + verificationMode: 'none', + }, + customHostSettings: [ + { + url, + ssl: { + 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({ useHttps: true }); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, ssl: { verificationMode: 'none' } }], + }); + 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({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { 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({ useHttps: true }); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); + + // targetHttps, proxyHttps, and proxyAuth should all range over [false, true], but + // currently the true versions are not passing + describe(`proxy`, () => { + for (const targetHttps of [false]) { + for (const targetAuth of [false, true]) { + for (const proxyHttps of [false]) { + for (const proxyAuth of [false]) { + const targetLabel = testLabel('target', targetHttps, targetAuth); + const proxyLabel = testLabel('proxy', proxyHttps, proxyAuth); + const testName = `${targetLabel} :: ${proxyLabel}`; + + const opts = { targetHttps, targetAuth, proxyHttps, proxyAuth }; + + test(`basic; ${testName}`, async () => await basicProxyTest(opts)); + + if (targetAuth) { + test(`wrong target password; ${testName}`, async () => + await wrongTargetPasswordProxyTest(opts)); + + test(`missing target password; ${testName}`, async () => + await missingTargetPasswordProxyTest(opts)); + } + + if (proxyAuth) { + test(`wrong proxy password; ${testName}`, async () => + await wrongProxyPasswordProxyTest(opts)); + + test(`missing proxy password; ${testName}`, async () => + await missingProxyPasswordProxyTest(opts)); + } + + if (targetHttps) { + test(`missing CA; ${testName}`, async () => + await missingCaProxyTest(opts)); + + test(`rejectUnauthorized target; ${testName}`, async () => + await rejectUnauthorizedTargetProxyTest(opts)); + + test(`custom CA target; ${testName}`, async () => + await customCAProxyTest(opts)); + + test(`verModeNone target; ${testName}`, async () => + await verModeNoneTargetProxyTest(opts)); + } + } + } + } + } + }); +}); + +async function basicProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + }); +} + +async function wrongTargetPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const wrongUrl = manglePassword(target.url); + const res = await request({ ...axiosDefaults, url: wrongUrl, configurationUtilities: acu }); + expect(res.status).toBe(403); + }); +} + +async function missingTargetPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const anonUrl = removePassword(target.url); + const res = await request({ ...axiosDefaults, url: anonUrl, configurationUtilities: acu }); + expect(res.status).toBe(401); + }); +} + +async function wrongProxyPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const wrongUrl = manglePassword(proxy.url); + const acu = getACUfromConfig({ + proxyUrl: wrongUrl, + ssl: { verificationMode: 'none' }, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.message).toMatch('407'); + } + }); +} + +async function missingProxyPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const anonUrl = removePassword(proxy.url); + const acu = getACUfromConfig({ + proxyUrl: anonUrl, + ssl: { verificationMode: 'none' }, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.message).toMatch('407'); + } + }); +} + +async function missingCaProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.code).toEqual('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + } + }); +} + +async function rejectUnauthorizedTargetProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + rejectUnauthorized: false, + customHostSettings: [{ url: target.url, ssl: { verificationMode: 'none' } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + }); +} + +async function customCAProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + }); +} + +async function verModeNoneTargetProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + customHostSettings: [{ url: target.url, ssl: { verificationMode: 'none' } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + }); +} + +interface RunTestOptions { + targetHttps: boolean; + targetAuth: boolean; + proxyHttps: boolean; + proxyAuth: boolean; +} + +type AxiosParams = Parameters[0]; + +type Test = ( + target: CreateServerResult, + proxy: CreateProxyResult, + axiosDefaults: AxiosParams +) => Promise; + +async function runWithSetup(opts: RunTestOptions, fn: Test) { + const target = await createServer({ + useHttps: opts.targetHttps, + requireAuth: opts.targetAuth, + }); + + const proxy = await createProxy({ + useHttps: opts.proxyHttps, + requireAuth: opts.proxyAuth, + }); + + const axiosDefaults = { + axios, + logger, + validateStatus, + url: target.url, + configurationUtilities: getACUfromConfig({ + proxyUrl: proxy.url, + }), + }; + + try { + await fn(target, proxy, axiosDefaults); + } catch (err) { + expect(err).toBeUndefined(); + } + + target.server.close(); + proxy.server.close(); +} + +function testLabel(type: string, tls: boolean, auth: boolean) { + return `${type} https ${tls ? 'X' : '-'} auth ${auth ? 'X' : '-'}`; +} + +function validateStatus(status: number) { + return true; +} + +function manglePassword(url: string) { + const parsed = new URL(url); + parsed.password = `nope-${parsed.password}-nope`; + return parsed.toString(); +} + +function removePassword(url: string) { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); +} + +const TlsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, +}; + +interface CreateServerOptions { + useHttps: boolean; + requireAuth?: boolean; +} + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(options: CreateServerOptions): Promise { + const { useHttps, requireAuth = false } = options; + const port = await getPort(); + const url = `http${useHttps ? 's' : ''}://${requireAuth ? `${Auth}@` : ''}localhost:${port}`; + + function requestHandler(req: http.IncomingMessage, res: http.ServerResponse) { + if (requireAuth) { + const auth = req.headers.authorization; + if (auth == null) { + res.setHeader('WWW-Authenticate', 'Basic'); + res.writeHead(401); + res.end('authorization required'); + return; + } + if (auth !== `Basic ${AuthB64}`) { + res.writeHead(403); + res.end('not authorized'); + return; + } + } + + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + } + + let server: http.Server | https.Server; + if (!useHttps) { + server = http.createServer(requestHandler); + } else { + server = https.createServer(TlsOptions, requestHandler); + } + server.unref(); + + const readySignal = createReadySignal(); + server.listen(port, 'localhost', () => { + readySignal.signal({ url, server }); + }); + + return readySignal.wait(); +} + +interface CreateProxyOptions { + useHttps: boolean; + requireAuth?: boolean; +} + +interface CreateProxyResult { + url: string; + server: http.Server | https.Server; +} + +type AuthenticateCallback = (err: null | Error, authenticated: boolean) => void; + +interface IAuthenticate { + authenticate(req: http.IncomingMessage, callback: AuthenticateCallback): void; +} + +async function createProxy(options: CreateProxyOptions): Promise { + const { useHttps, requireAuth = false } = options; + const port = await getPort(); + const url = getUrl(useHttps, requireAuth, port); + let proxyServer: http.Server | https.Server; + + if (!useHttps) { + proxyServer = http.createServer(); + } else { + proxyServer = https.createServer(TlsOptions); + } + proxyServer.unref(); + + proxySetup(proxyServer); + if (requireAuth) { + (proxyServer as unknown as IAuthenticate).authenticate = (req, callback) => { + const auth = req.headers['proxy-authorization']; + callback(null, auth === `Basic ${AuthB64}`); + }; + } + + const readySignal = createReadySignal(); + + proxyServer.listen(port, 'localhost', () => { + readySignal.signal({ server: proxyServer, url }); + }); + + return readySignal.wait(); +} + +function getUrl(useHttps: boolean, requiresAuth: boolean, port: number) { + return `http${useHttps ? 's' : ''}://${requiresAuth ? `${Auth}@` : ''}localhost:${port}`; +} + +const BaseActionsConfig: ActionsConfig = { + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + ssl: { + proxyVerificationMode: 'full', + verificationMode: 'full', + }, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + const resolvedConfig = resolveCustomHosts(logger, { ...BaseActionsConfig, ...config }); + return getActionsConfigurationUtilities(resolvedConfig); +} diff --git a/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts b/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts new file mode 100644 index 00000000000000..48aca5cd7381fb --- /dev/null +++ b/x-pack/plugins/actions/server/integration_tests/axios_utils_proxy.test.ts @@ -0,0 +1,607 @@ +/* + * 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. + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const proxySetup = require('proxy'); + +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 getPort from 'get-port'; + +import { request } from '../builtin_action_types/lib/axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createReadySignal } from '@kbn/event-log-plugin/server/lib/ready_signal'; +import { ActionsConfig } from '../config'; +import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from '../actions_config'; +import { resolveCustomHosts } from '../lib/custom_host_settings'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +const Auth = 'elastic:changeme'; +const AuthB64 = Buffer.from(Auth).toString('base64'); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const AxiosDefaultsAadapter = require('axios/lib/adapters/http'); + +const ServerResponse = 'A unique response returned by the server!'; + +describe('axios connections', () => { + let testServer: http.Server | https.Server | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeEach(() => { + // 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 = AxiosDefaultsAadapter; + }); + + afterEach(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer?.close(); + testServer = null; + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer({ useHttps: false }); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer({ useHttps: 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 verificationMode "none" config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + ssl: { + verificationMode: 'none', + }, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + + test('it works with verificationMode "none" for custom host config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { verificationMode: 'none' } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { 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 verificationMode "none"', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + ssl: { + certificateAuthoritiesData: CA, + verificationMode: 'none', + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + + test('it works with incorrect ca in custom host config but verificationMode config "full"', async () => { + const { url, server } = await createServer({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + ssl: { + verificationMode: 'none', + }, + customHostSettings: [ + { + url, + ssl: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer({ useHttps: true }); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, ssl: { verificationMode: 'none' } }], + }); + 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({ useHttps: true }); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { 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({ useHttps: true }); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, ssl: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); + + // targetHttps, proxyHttps, and proxyAuth should all range over [false, true], but + // currently the true versions are not passing + describe(`proxy`, () => { + for (const targetHttps of [false]) { + for (const targetAuth of [false, true]) { + for (const proxyHttps of [false]) { + for (const proxyAuth of [false]) { + const targetLabel = testLabel('target', targetHttps, targetAuth); + const proxyLabel = testLabel('proxy', proxyHttps, proxyAuth); + const testName = `${targetLabel} :: ${proxyLabel}`; + + const opts = { targetHttps, targetAuth, proxyHttps, proxyAuth }; + + test(`basic; ${testName}`, async () => await basicProxyTest(opts)); + + if (targetAuth) { + test(`wrong target password; ${testName}`, async () => + await wrongTargetPasswordProxyTest(opts)); + + test(`missing target password; ${testName}`, async () => + await missingTargetPasswordProxyTest(opts)); + } + + if (proxyAuth) { + test(`wrong proxy password; ${testName}`, async () => + await wrongProxyPasswordProxyTest(opts)); + + test(`missing proxy password; ${testName}`, async () => + await missingProxyPasswordProxyTest(opts)); + } + + if (targetHttps) { + test(`missing CA; ${testName}`, async () => + await missingCaProxyTest(opts)); + + test(`rejectUnauthorized target; ${testName}`, async () => + await rejectUnauthorizedTargetProxyTest(opts)); + + test(`custom CA target; ${testName}`, async () => + await customCAProxyTest(opts)); + + test(`verModeNone target; ${testName}`, async () => + await verModeNoneTargetProxyTest(opts)); + } + } + } + } + } + }); +}); + +async function basicProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); +} + +async function wrongTargetPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const wrongUrl = manglePassword(target.url); + const res = await request({ ...axiosDefaults, url: wrongUrl, configurationUtilities: acu }); + expect(res.status).toBe(403); + }); +} + +async function missingTargetPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + ssl: { verificationMode: 'none' }, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const anonUrl = removePassword(target.url); + const res = await request({ ...axiosDefaults, url: anonUrl, configurationUtilities: acu }); + expect(res.status).toBe(401); + }); +} + +async function wrongProxyPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const wrongUrl = manglePassword(proxy.url); + const acu = getACUfromConfig({ + proxyUrl: wrongUrl, + ssl: { verificationMode: 'none' }, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.message).toMatch('407'); + } + }); +} + +async function missingProxyPasswordProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const anonUrl = removePassword(proxy.url); + const acu = getACUfromConfig({ + proxyUrl: anonUrl, + ssl: { verificationMode: 'none' }, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.message).toMatch('407'); + } + }); +} + +async function missingCaProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + }); + + try { + await request({ ...axiosDefaults, configurationUtilities: acu }); + expect('request should have thrown error').toBeUndefined(); + } catch (err) { + expect(err.code).toEqual('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + } + }); +} + +async function rejectUnauthorizedTargetProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + rejectUnauthorized: false, + customHostSettings: [{ url: target.url, ssl: { verificationMode: 'none' } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); +} + +async function customCAProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + customHostSettings: [{ url: target.url, ssl: { certificateAuthoritiesData: CA } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); +} + +async function verModeNoneTargetProxyTest(opts: RunTestOptions) { + await runWithSetup(opts, async (target, proxy, axiosDefaults) => { + const acu = getACUfromConfig({ + proxyUrl: proxy.url, + customHostSettings: [{ url: target.url, ssl: { verificationMode: 'none' } }], + }); + + const res = await request({ ...axiosDefaults, configurationUtilities: acu }); + expect(res.status).toBe(200); + expect(res.data).toBe(ServerResponse); + }); +} + +interface RunTestOptions { + targetHttps: boolean; + targetAuth: boolean; + proxyHttps: boolean; + proxyAuth: boolean; +} + +type AxiosParams = Parameters[0]; + +type Test = ( + target: CreateServerResult, + proxy: CreateProxyResult, + axiosDefaults: AxiosParams +) => Promise; + +async function runWithSetup(opts: RunTestOptions, fn: Test) { + const target = await createServer({ + useHttps: opts.targetHttps, + requireAuth: opts.targetAuth, + }); + + const proxy = await createProxy({ + useHttps: opts.proxyHttps, + requireAuth: opts.proxyAuth, + }); + + const axiosDefaults = { + axios, + logger, + validateStatus, + url: target.url, + configurationUtilities: getACUfromConfig({ + proxyUrl: proxy.url, + }), + }; + + try { + await fn(target, proxy, axiosDefaults); + } catch (err) { + expect(err).toBeUndefined(); + } + + target.server.close(); + proxy.server.close(); +} + +function testLabel(type: string, tls: boolean, auth: boolean) { + return `${type} https ${tls ? 'X' : '-'} auth ${auth ? 'X' : '-'}`; +} + +function validateStatus(status: number) { + return true; +} + +function manglePassword(url: string) { + const parsed = new URL(url); + parsed.password = `nope-${parsed.password}-nope`; + return parsed.toString(); +} + +function removePassword(url: string) { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); +} + +const TlsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, +}; + +interface CreateServerOptions { + useHttps: boolean; + requireAuth?: boolean; +} + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(options: CreateServerOptions): Promise { + const { useHttps, requireAuth = false } = options; + const port = await getPort(); + const url = `http${useHttps ? 's' : ''}://${requireAuth ? `${Auth}@` : ''}localhost:${port}`; + + function requestHandler(req: http.IncomingMessage, res: http.ServerResponse) { + if (requireAuth) { + const auth = req.headers.authorization; + if (auth == null) { + res.setHeader('WWW-Authenticate', 'Basic'); + res.writeHead(401); + res.end('authorization required'); + return; + } + if (auth !== `Basic ${AuthB64}`) { + res.writeHead(403); + res.end('not authorized'); + return; + } + } + + res.writeHead(200); + res.end(ServerResponse); + } + + let server: http.Server | https.Server; + if (!useHttps) { + server = http.createServer(requestHandler); + } else { + server = https.createServer(TlsOptions, requestHandler); + } + server.unref(); + + const readySignal = createReadySignal(); + server.listen(port, 'localhost', () => { + readySignal.signal({ url, server }); + }); + + return readySignal.wait(); +} + +interface CreateProxyOptions { + useHttps: boolean; + requireAuth?: boolean; +} + +interface CreateProxyResult { + url: string; + server: http.Server | https.Server; +} + +type AuthenticateCallback = (err: null | Error, authenticated: boolean) => void; + +interface IAuthenticate { + authenticate(req: http.IncomingMessage, callback: AuthenticateCallback): void; +} + +async function createProxy(options: CreateProxyOptions): Promise { + const { useHttps, requireAuth = false } = options; + const port = await getPort(); + const url = getUrl(useHttps, requireAuth, port); + let proxyServer: http.Server | https.Server; + + if (!useHttps) { + proxyServer = http.createServer(); + } else { + proxyServer = https.createServer(TlsOptions); + } + proxyServer.unref(); + + proxySetup(proxyServer); + if (requireAuth) { + (proxyServer as unknown as IAuthenticate).authenticate = (req, callback) => { + const auth = req.headers['proxy-authorization']; + callback(null, auth === `Basic ${AuthB64}`); + }; + } + + const readySignal = createReadySignal(); + + proxyServer.listen(port, 'localhost', () => { + readySignal.signal({ server: proxyServer, url }); + }); + + return readySignal.wait(); +} + +function getUrl(useHttps: boolean, requiresAuth: boolean, port: number) { + return `http${useHttps ? 's' : ''}://${requiresAuth ? `${Auth}@` : ''}localhost:${port}`; +} + +const BaseActionsConfig: ActionsConfig = { + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + ssl: { + proxyVerificationMode: 'full', + verificationMode: 'full', + }, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + const resolvedConfig = resolveCustomHosts(logger, { ...BaseActionsConfig, ...config }); + return getActionsConfigurationUtilities(resolvedConfig); +} diff --git a/x-pack/plugins/actions/server/manual_tests/forward_proxy.js b/x-pack/plugins/actions/server/manual_tests/forward_proxy.js index e562d3edabdb6e..17475866b31110 100644 --- a/x-pack/plugins/actions/server/manual_tests/forward_proxy.js +++ b/x-pack/plugins/actions/server/manual_tests/forward_proxy.js @@ -6,108 +6,151 @@ */ /* -This module implements two forward http proxies, http on 8080 and https on 8443, -which can be used with the config xpack.actions.proxyUrl to emulate customers -using forward proxies with Kibana actions. You can use either the http or https -versions, both can forward proxy http and https traffic: - - xpack.actions.proxyUrl: http://localhost:8080 - OR - xpack.actions.proxyUrl: https://localhost:8443 +Starts http and https proxies to use to test actions within Kibana or with curl. -When using the https-based version, you may need to set the following option -as well: +Assumes you have elasticsearch running on https://elastic:changeme@localhost:9200, +otherwise expect 500 responses from those requests. All other requests should +work as expected. - xpack.actions.rejectUnauthorized: false +# start 4 proxies: -If the server you are connecting to via the proxy is https and has self-signed -certificates, you'll also need to set +node x-pack/plugins/actions/server/manual_tests/forward_proxy.js http-8080-open http-8081-auth https-8443-open https-8444-auth - xpack.actions.proxyRejectUnauthorizedCertificates: false -*/ +# issue some requests through the proxies -const HTTP_PORT = 8080; -const HTTPS_PORT = 8443; +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8080 http://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8080 https://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8443 http://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8443 https://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8080 https://elastic:changeme@localhost:9200; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8443 https://elastic:changeme@localhost:9200; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8081 --proxy-user elastic:changeme http://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8081 --proxy-user elastic:changeme https://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8444 --proxy-user elastic:changeme http://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8444 --proxy-user elastic:changeme https://www.example.com; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x http://127.0.0.1:8081 --proxy-user elastic:changeme https://elastic:changeme@localhost:9200; \ +curl -k --no-alpn -o /dev/null --proxy-insecure -x https://127.0.0.1:8444 --proxy-user elastic:changeme https://elastic:changeme@localhost:9200; \ +echo done - you should run all the lines above as one command -// starts http and https proxies to use to test actions within Kibana +*/ const fs = require('fs'); -const net = require('net'); -const url = require('url'); +const path = require('path'); const http = require('http'); const https = require('https'); -const httpProxy = require('http-proxy'); +const proxySetup = require('proxy'); + +const PROGRAM = path.basename(__filename).replace(/.js$/, ''); +const CertDir = path.resolve(__dirname, '../../../../../packages/kbn-dev-utils/certs'); -const httpsOptions = { - key: fs.readFileSync('packages/kbn-dev-utils/certs/kibana.key', 'utf8'), - cert: fs.readFileSync('packages/kbn-dev-utils/certs/kibana.crt', 'utf8'), +const Auth = 'elastic:changeme'; +const AuthB64 = Buffer.from(Auth).toString('base64'); + +const HttpsOptions = { + key: fs.readFileSync(path.join(CertDir, 'kibana.key'), 'utf8'), + cert: fs.readFileSync(path.join(CertDir, 'kibana.crt'), 'utf8'), }; -const proxy = httpProxy.createServer(); +async function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + help(); + process.exit(1); + } -createServer('http', HTTP_PORT); -createServer('https', HTTPS_PORT); + const specs = args.map(argToSpec); + for (const spec of specs) { + const { protocol, port, auth } = spec; + createServer(protocol, port, auth); + } +} -function createServer(protocol, port) { - let httpServer; +/** @type { (protocol: string, port: number, auth: boolean) => Promise } */ +async function createServer(protocol, port, auth) { + let proxyServer; if (protocol === 'http') { - httpServer = http.createServer(); + proxyServer = http.createServer(); } else { - httpServer = https.createServer(httpsOptions); + proxyServer = https.createServer(HttpsOptions); } - httpServer.on('request', httpRequest); - httpServer.on('connect', httpsRequest); - httpServer.listen(port); - log(`proxy server started: ${protocol}:/localhost:${port}`); - - // handle http requests - function httpRequest(req, res) { - log(`${protocol} server: request for: ${req.url}`); - const parsedUrl = url.parse(req.url); - if (parsedUrl.hostname == null) { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('this is a proxy server'); - return; - } - const target = parsedUrl.protocol + '//' + parsedUrl.hostname; - proxy.web(req, res, { target: target, secure: false }); + proxySetup(proxyServer); + + let authLabel = ''; + if (auth) { + authLabel = `${Auth}@`; + proxyServer.authenticate = (req, callback) => { + const auth = req.headers['proxy-authorization']; + callback(null, auth === `Basic ${AuthB64}`); + }; } - // handle https requests - // see: https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_event_connect - function httpsRequest(req, socket, head) { - log(`${protocol} proxy server: request for target: https://${req.url}`); - const serverUrl = url.parse('https://' + req.url); - const serverSocket = net.connect(serverUrl.port, serverUrl.hostname, () => { - socket.write('HTTP/1.1 200 Connection Established\r\nProxy-agent: Node-Proxy\r\n\r\n'); - serverSocket.write(head); - serverSocket.pipe(socket); - socket.pipe(serverSocket); - }); - socket.on('error', (err) => { - log(`error on socket to proxy: ${err}`); - socket.destroy(); - serverSocket.destroy(); - }); - serverSocket.on('error', (err) => { - log(`error on socket to target: ${err}`); - socket.destroy(); - serverSocket.destroy(); - }); + const serverLabel = `${protocol}://${authLabel}localhost:${port}`; + proxyServer.listen(port, 'localhost', () => { + console.log(`proxy server started on ${serverLabel}`); + }); +} + +/* convert 'proto-port-auth' into object with shape shown below */ +/** @type { (arg: string) => void | { protocol: string, port: number, auth: boolean } } */ +function argToSpec(arg) { + const parts = arg.split('-'); + if (parts.length < 2) { + return logError(`invalid spec: ${arg}`); } + + const [protocol, portString, authString] = parts; + + if (!protocol) return logError(`empty protocol in '${arg}'`); + if (protocol !== 'http' && protocol !== 'https') + return logError(`invalid protocol in '${arg}': '${protocol}'`); + + if (!portString) return logError(`empty port in '${arg}'`); + const port = Number.parseInt(portString, 10); + if (isNaN(port)) return logError(`invalid port in '${arg}': ${portString}`); + + let auth; + if (!authString) { + auth = false; + } else { + if (authString !== 'auth' && authString !== 'open') + return logError(`invalid auth in '${arg}': '${authString}'`); + auth = authString === 'auth'; + } + + return { protocol, port, auth }; } +/** @type { (message: string) => void } */ function log(message) { console.log(`${new Date().toISOString()} - ${message}`); } -/* -Test with: +/** @type { (message: string) => void } */ +function logError(message) { + log(message); + process.exit(1); +} -curl -v -k --proxy-insecure -x http://127.0.0.1:8080 http://www.google.com -curl -v -k --proxy-insecure -x http://127.0.0.1:8080 https://www.google.com -curl -v -k --proxy-insecure -x https://127.0.0.1:8443 http://www.google.com -curl -v -k --proxy-insecure -x https://127.0.0.1:8443 https://www.google.com -*/ +main(); + +function help() { + console.log(`${PROGRAM} - create http proxies to test connectors with`); + console.log(`usage:`); + console.log(` ${PROGRAM} spec spec spec ...`); + console.log(``); + console.log(`options:`); + console.log(` - none yet`); + console.log(``); + console.log(`parameters:`); + console.log(` spec: spec is a 3-part token, separated by '-' chars`); + console.log(` [proto]-[port]-[auth]`); + console.log(` proto - 'http' or 'https'`); + console.log(` port - port to open the proxy on`); + console.log(` auth - 'auth' or 'open' (auth expects user/pass elastic:change)`); + console.log(``); + console.log(`example:`); + console.log(` ${PROGRAM} {options} http-8080-open https-8443-open`); + console.log(` `); +} diff --git a/yarn.lock b/yarn.lock index 046631d881936e..8bd24af28d3a77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9689,6 +9689,16 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +args@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/args/-/args-5.0.1.tgz#4bf298df90a4799a09521362c579278cc2fdd761" + integrity sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ== + dependencies: + camelcase "5.0.0" + chalk "2.4.2" + leven "2.1.0" + mri "1.1.4" + argsplit@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/argsplit/-/argsplit-1.0.5.tgz#9319a6ef63411716cfeb216c45ec1d13b35c5e99" @@ -10388,6 +10398,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +basic-auth-parser@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/basic-auth-parser/-/basic-auth-parser-0.0.2.tgz#ce9e71a77f23c1279eecd2659b2a46244c156e41" + integrity sha512-Y7OBvWn+JnW45JWHLY6ybYub2k9cXCMrtCyO1Hds2s6eqClqWhPnOQpgXUPjAiMHj+A8TEPIQQ1dYENnJoBOHQ== + basic-auth@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" @@ -11104,6 +11119,11 @@ camelcase-keys@^6.2.2: map-obj "^4.0.0" quick-lru "^4.0.1" +camelcase@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" + integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== + camelcase@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" @@ -11204,6 +11224,15 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.5.tgz#ac82a944905a65ce204eb03023157edf29425c17" integrity sha512-MOli1W+nfbPLlKEhInaxhRdp7KVLFxLN5ykwzHgLsLI3H3gs5jjFAK4Eoj3OzzcxCtumDaI8onoVDeQyWaNTkw== +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -11231,15 +11260,6 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - chalk@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" @@ -19524,6 +19544,11 @@ lead@^1.0.0: dependencies: flush-write-stream "^1.0.2" +leven@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + integrity sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -20877,6 +20902,11 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +mri@1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" + integrity sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w== + mrmime@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.0.tgz#14d387f0585a5233d291baba339b063752a2398b" @@ -23314,6 +23344,15 @@ proxy-from-env@1.1.0, proxy-from-env@^1.0.0, proxy-from-env@^1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/proxy/-/proxy-1.0.2.tgz#e0cfbe11c0a7a8b238fd2d7134de4e2867578e7f" + integrity sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ== + dependencies: + args "5.0.1" + basic-auth-parser "0.0.2" + debug "^4.1.1" + prr@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"