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:
parent
4238ecfb25
commit
fdbdddf6b2
|
@ -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,
|
||||
},
|
||||
}));
|
|
@ -12,3 +12,5 @@ export {
|
|||
mockRequestHandler,
|
||||
mockDependencies,
|
||||
} from './routerDependencies.mock';
|
||||
|
||||
export { mockHttpAgent } from './http_agent.mock';
|
||||
|
|
|
@ -23,6 +23,7 @@ export const mockConfig = {
|
|||
host: 'http://localhost:3002',
|
||||
accessCheckTimeout: 5000,
|
||||
accessCheckTimeoutWarning: 300,
|
||||
ssl: {},
|
||||
} as ConfigType;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue