From fdbdddf6b28eebc182f58c9971e2dd7423092d45 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:55:06 -0400 Subject: [PATCH] Enterpise Search SSL Settings Support (#100946) (#101286) Introduce a new set of SSL configuration settings for Enterprise Search plugin, allowing users to configure a set of custom certificate authorities and to control TLS validation mode used for all requests to Enterprise Search. Co-authored-by: Byron Hulcher Co-authored-by: Constance Chen Co-authored-by: Oleksiy Kovyrin Co-authored-by: Byron Hulcher Co-authored-by: Constance Chen --- .../server/__mocks__/http_agent.mock.ts | 14 +++ .../server/__mocks__/index.ts | 2 + .../__mocks__/routerDependencies.mock.ts | 1 + .../plugins/enterprise_search/server/index.ts | 9 ++ .../lib/enterprise_search_config_api.test.ts | 1 + .../lib/enterprise_search_config_api.ts | 9 +- .../lib/enterprise_search_http_agent.test.ts | 118 ++++++++++++++++++ .../lib/enterprise_search_http_agent.ts | 85 +++++++++++++ .../enterprise_search_request_handler.test.ts | 3 +- .../lib/enterprise_search_request_handler.ts | 15 ++- .../enterprise_search/server/plugin.ts | 6 + 11 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts new file mode 100644 index 000000000000..1e9b04674b58 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export const mockHttpAgent = jest.fn(); + +jest.mock('../lib/enterprise_search_http_agent', () => ({ + entSearchHttpAgent: { + getHttpAgent: () => mockHttpAgent, + }, +})); diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts index c36acd2b5764..c59a5a8f67e3 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts @@ -12,3 +12,5 @@ export { mockRequestHandler, mockDependencies, } from './routerDependencies.mock'; + +export { mockHttpAgent } from './http_agent.mock'; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 50ff082858fc..08be1a134ae0 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -23,6 +23,7 @@ export const mockConfig = { host: 'http://localhost:3002', accessCheckTimeout: 5000, accessCheckTimeoutWarning: 300, + ssl: {}, } as ConfigType; /** diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index c4552b9134ea..ecd068c8bdbd 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,6 +19,15 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), accessCheckTimeout: schema.number({ defaultValue: 5000 }), accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), + ssl: schema.object({ + certificateAuthorities: schema.maybe( + schema.oneOf([schema.arrayOf(schema.string(), { minSize: 1 }), schema.string()]) + ), + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index 66f2bf78e0c9..50bac793ee69 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../common/__mocks__'; +import '../__mocks__/http_agent.mock.ts'; jest.mock('node-fetch'); import fetch from 'node-fetch'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 0f2faf1fd8a3..8cce01d1932e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -16,6 +16,8 @@ import { stripTrailingSlash } from '../../common/strip_slashes'; import { InitialAppData } from '../../common/types'; import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + interface Params { request: KibanaRequest; config: ConfigType; @@ -54,10 +56,13 @@ export const callEnterpriseSearchConfigAPI = async ({ try { const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); - const response = await fetch(enterpriseSearchUrl, { + const options = { headers: { Authorization: request.headers.authorization as string }, signal: controller.signal, - }); + agent: entSearchHttpAgent.getHttpAgent(), + }; + + const response = await fetch(enterpriseSearchUrl, options); const data = await response.json(); warnMismatchedVersions(data?.version?.number, log); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts new file mode 100644 index 000000000000..f4bdfd8d2cb0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts @@ -0,0 +1,118 @@ +/* + * 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. + */ + +jest.mock('fs', () => ({ readFileSync: jest.fn() })); +import { readFileSync } from 'fs'; + +import http from 'http'; +import https from 'https'; + +import { ConfigType } from '../'; + +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + +describe('entSearchHttpAgent', () => { + describe('initializeHttpAgent', () => { + it('creates an https.Agent when host URL is using HTTPS', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'https://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(https.Agent); + }); + + it('creates an http.Agent when host URL is using HTTP', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'http://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + describe('fallbacks', () => { + it('initializes a http.Agent when host URL is invalid', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: '##!notarealurl#$', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + it('should be an http.Agent when host URL is empty', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: undefined, + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + }); + }); + + describe('loadCertificateAuthorities', () => { + describe('happy path', () => { + beforeEach(() => { + jest.clearAllMocks(); + (readFileSync as jest.Mock).mockImplementation((path: string) => `content-of-${path}`); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is a string', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities('some-path'); + expect(readFileSync).toHaveBeenCalledTimes(1); + expect(certs).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is an array', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(['some-path', 'another-path']); + expect(readFileSync).toHaveBeenCalledTimes(2); + expect(certs).toEqual(['content-of-some-path', 'content-of-another-path']); + }); + + it('does not read anything when ssl.certificateAuthorities is empty', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(undefined); + expect(readFileSync).toHaveBeenCalledTimes(0); + expect(certs).toEqual([]); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + (readFileSync as jest.Mock).mockImplementation((path: string) => realFs.readFileSync(path)); + }); + + it('throws if certificateAuthorities is invalid', () => { + expect(() => entSearchHttpAgent.loadCertificateAuthorities('/invalid/ca')).toThrow( + "ENOENT: no such file or directory, open '/invalid/ca'" + ); + }); + }); + }); + + describe('getAgentOptions', () => { + it('verificationMode: none', () => { + expect(entSearchHttpAgent.getAgentOptions('none')).toEqual({ + rejectUnauthorized: false, + }); + }); + + it('verificationMode: certificate', () => { + expect(entSearchHttpAgent.getAgentOptions('certificate')).toEqual({ + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + }); + + const { checkServerIdentity } = entSearchHttpAgent.getAgentOptions('certificate') as any; + expect(checkServerIdentity()).toEqual(undefined); + }); + + it('verificationMode: full', () => { + expect(entSearchHttpAgent.getAgentOptions('full')).toEqual({ + rejectUnauthorized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts new file mode 100644 index 000000000000..89210def248b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from 'fs'; +import http from 'http'; +import https from 'https'; +import { PeerCertificate } from 'tls'; + +import { ConfigType } from '../'; + +export type HttpAgent = http.Agent | https.Agent; +interface AgentOptions { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; +} + +/* + * Returns an HTTP agent to be used for requests to Enterprise Search APIs + */ +class EnterpriseSearchHttpAgent { + public httpAgent: HttpAgent = new http.Agent(); + + getHttpAgent() { + return this.httpAgent; + } + + initializeHttpAgent(config: ConfigType) { + if (!config.host) return; + + try { + const parsedHost = new URL(config.host); + if (parsedHost.protocol === 'https:') { + this.httpAgent = new https.Agent({ + ca: this.loadCertificateAuthorities(config.ssl.certificateAuthorities), + ...this.getAgentOptions(config.ssl.verificationMode), + }); + } + } catch { + // Ignore URL parsing errors and fall back to the HTTP agent + } + } + + /* + * Loads custom CA certificate files and returns all certificates as an array + * This is a potentially expensive operation & why this helper is a class + * initialized once on plugin init + */ + loadCertificateAuthorities(certificates: string | string[] | undefined): string[] { + if (!certificates) return []; + + const paths = Array.isArray(certificates) ? certificates : [certificates]; + return paths.map((path) => readFileSync(path, 'utf8')); + } + + /* + * Convert verificationMode to rejectUnauthorized for more consistent config settings + * with the rest of Kibana + * @see https://github.com/elastic/kibana/blob/master/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts + */ + getAgentOptions(verificationMode: 'full' | 'certificate' | 'none') { + const agentOptions: AgentOptions = {}; + + switch (verificationMode) { + case 'none': + agentOptions.rejectUnauthorized = false; + break; + case 'certificate': + agentOptions.rejectUnauthorized = true; + agentOptions.checkServerIdentity = () => undefined; + break; + case 'full': + default: + agentOptions.rejectUnauthorized = true; + break; + } + + return agentOptions; + } +} + +export const entSearchHttpAgent = new EnterpriseSearchHttpAgent(); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 3223471e4fc1..6ebf46abd39d 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockConfig, mockLogger } from '../__mocks__'; +import { mockConfig, mockLogger, mockHttpAgent } from '../__mocks__'; import { ENTERPRISE_SEARCH_KIBANA_COOKIE, @@ -476,6 +476,7 @@ const EnterpriseSearchAPI = { headers: { Authorization: 'Basic 123', ...JSON_HEADER }, method: 'GET', body: undefined, + agent: mockHttpAgent, ...expectedParams, }); }, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 2fc0a13f2ff7..597f7524808e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,13 +16,15 @@ import { Logger, } from 'src/core/server'; +import { ConfigType } from '../'; + import { ENTERPRISE_SEARCH_KIBANA_COOKIE, JSON_HEADER, READ_ONLY_MODE_HEADER, } from '../../common/constants'; -import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; interface ConstructorDependencies { config: ConfigType; @@ -77,12 +79,15 @@ export class EnterpriseSearchRequestHandler { const url = encodeURI(this.enterpriseSearchUrl) + encodedPath + queryString; // Set up API options - const { method } = request.route; - const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER }; - const body = this.getBodyAsString(request.body as object | Buffer); + const options = { + method: request.route.method as string, + headers: { Authorization: request.headers.authorization as string, ...JSON_HEADER }, + body: this.getBodyAsString(request.body as object | Buffer), + agent: entSearchHttpAgent.getHttpAgent(), + }; // Call the Enterprise Search API - const apiResponse = await fetch(url, { method, headers, body }); + const apiResponse = await fetch(url, options); // Handle response headers this.setResponseHeaders(apiResponse); diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1b9659899097..04bd304ee679 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -31,6 +31,7 @@ import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { checkAccess } from './lib/check_access'; +import { entSearchHttpAgent } from './lib/enterprise_search_http_agent'; import { EnterpriseSearchRequestHandler, IEnterpriseSearchRequestHandler, @@ -81,6 +82,11 @@ export class EnterpriseSearchPlugin implements Plugin { const config = this.config; const log = this.logger; + /* + * Initialize config.ssl.certificateAuthorities file(s) - required for all API calls (+ access checks) + */ + entSearchHttpAgent.initializeHttpAgent(config); + /** * Register space/feature control */