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 <byronhulcher@gmail.com>
Co-authored-by: Constance Chen <constance.chen.3@gmail.com>

Co-authored-by: Oleksiy Kovyrin <oleksiy@kovyrin.net>
Co-authored-by: Byron Hulcher <byronhulcher@gmail.com>
Co-authored-by: Constance Chen <constance.chen.3@gmail.com>
This commit is contained in:
Kibana Machine 2021-06-03 12:55:06 -04:00 committed by GitHub
parent 4238ecfb25
commit fdbdddf6b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 255 additions and 8 deletions

View file

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

View file

@ -12,3 +12,5 @@ export {
mockRequestHandler,
mockDependencies,
} from './routerDependencies.mock';
export { mockHttpAgent } from './http_agent.mock';

View file

@ -23,6 +23,7 @@ export const mockConfig = {
host: 'http://localhost:3002',
accessCheckTimeout: 5000,
accessCheckTimeoutWarning: 300,
ssl: {},
} as ConfigType;
/**

View file

@ -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<typeof configSchema>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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