diff --git a/packages/core/http/core-http-server-internal/src/http_server.ts b/packages/core/http/core-http-server-internal/src/http_server.ts index 236b9567ddcb7..478d1d746bbac 100644 --- a/packages/core/http/core-http-server-internal/src/http_server.ts +++ b/packages/core/http/core-http-server-internal/src/http_server.ts @@ -10,14 +10,7 @@ import { Server, Request } from '@hapi/hapi'; import HapiStaticFiles from '@hapi/inert'; import url from 'url'; import { v4 as uuidv4 } from 'uuid'; -import { - createServer, - getListenerOptions, - getServerOptions, - setTlsConfig, - getRequestId, -} from '@kbn/server-http-tools'; - +import { createServer, getServerOptions, setTlsConfig, getRequestId } from '@kbn/server-http-tools'; import type { Duration } from 'moment'; import { Observable, Subscription, firstValueFrom, pairwise, take } from 'rxjs'; import apm from 'elastic-apm-node'; @@ -235,9 +228,8 @@ export class HttpServer { this.config = config; const serverOptions = getServerOptions(config); - const listenerOptions = getListenerOptions(config); - this.server = createServer(serverOptions, listenerOptions); + this.server = createServer(serverOptions); await this.server.register([HapiStaticFiles]); if (config.compression.brotli.enabled) { await this.server.register({ diff --git a/packages/core/http/core-http-server-internal/src/https_redirect_server.ts b/packages/core/http/core-http-server-internal/src/https_redirect_server.ts index 501c83377fe0a..2999c4aaf734e 100644 --- a/packages/core/http/core-http-server-internal/src/https_redirect_server.ts +++ b/packages/core/http/core-http-server-internal/src/https_redirect_server.ts @@ -8,7 +8,7 @@ import { Request, ResponseToolkit, Server } from '@hapi/hapi'; import { format as formatUrl } from 'url'; -import { createServer, getListenerOptions, getServerOptions } from '@kbn/server-http-tools'; +import { createServer, getServerOptions } from '@kbn/server-http-tools'; import type { Logger } from '@kbn/logging'; import { HttpConfig } from './http_config'; @@ -31,13 +31,10 @@ export class HttpsRedirectServer { // Redirect server is configured in the same way as any other HTTP server // within the platform with the only exception that it should always be a // plain HTTP server, so we just ignore `tls` part of options. - this.server = createServer( - { - ...getServerOptions(config, { configureTLS: false }), - port: config.ssl.redirectHttpFromPort, - }, - getListenerOptions(config) - ); + this.server = createServer({ + ...getServerOptions(config, { configureTLS: false }), + port: config.ssl.redirectHttpFromPort, + }); this.server.ext('onRequest', (request: Request, responseToolkit: ResponseToolkit) => { return responseToolkit diff --git a/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts index 46cd67e1e0642..f9aaad7923152 100644 --- a/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts +++ b/packages/kbn-cli-dev-mode/src/base_path_proxy_server.ts @@ -15,7 +15,7 @@ import { sampleSize } from 'lodash'; import * as Rx from 'rxjs'; import { take } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { createServer, getListenerOptions, getServerOptions } from '@kbn/server-http-tools'; +import { createServer, getServerOptions } from '@kbn/server-http-tools'; import { DevConfig, HttpConfig } from './config'; import { Log } from './log'; @@ -67,8 +67,7 @@ export class BasePathProxyServer { public async start(options: BasePathProxyServerOptions) { const serverOptions = getServerOptions(this.httpConfig); - const listenerOptions = getListenerOptions(this.httpConfig); - this.server = createServer(serverOptions, listenerOptions); + this.server = createServer(serverOptions); // Register hapi plugin that adds proxying functionality. It can be configured // through the route configuration object (see { handler: { proxy: ... } }). diff --git a/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts b/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts index 0f0a69638cfa2..432f67a75f1b0 100644 --- a/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts +++ b/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts @@ -10,12 +10,7 @@ import { Server } from '@hapi/hapi'; import { EMPTY } from 'rxjs'; import moment from 'moment'; import supertest from 'supertest'; -import { - getServerOptions, - getListenerOptions, - createServer, - IHttpConfig, -} from '@kbn/server-http-tools'; +import { getServerOptions, createServer, type IHttpConfig } from '@kbn/server-http-tools'; import { ByteSizeValue } from '@kbn/config-schema'; import { BasePathProxyServer, BasePathProxyServerOptions } from '../base_path_proxy_server'; @@ -51,8 +46,7 @@ describe('BasePathProxyServer', () => { }; const serverOptions = getServerOptions(config); - const listenerOptions = getListenerOptions(config); - server = createServer(serverOptions, listenerOptions); + server = createServer(serverOptions); // setup and start the proxy server const proxyConfig: IHttpConfig = { ...config, port: 10013 }; @@ -276,8 +270,7 @@ describe('BasePathProxyServer', () => { } as IHttpConfig; const serverOptions = getServerOptions(configWithBasePath); - const listenerOptions = getListenerOptions(configWithBasePath); - server = createServer(serverOptions, listenerOptions); + server = createServer(serverOptions); server.route({ method: 'GET', diff --git a/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts index 543fe9b29e9cc..657b1fc26b930 100644 --- a/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts +++ b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { sslSchema, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; +import { sslSchema, getServerOptions } from '@kbn/server-http-tools'; export const hapiStartMock = jest.fn(); export const hapiStopMock = jest.fn(); @@ -18,12 +18,10 @@ export const createServerMock = jest.fn().mockImplementation(() => ({ route: hapiRouteMock, })); export const getServerOptionsMock = jest.fn().mockImplementation(getServerOptions); -export const getListenerOptionsMock = jest.fn().mockImplementation(getListenerOptions); jest.doMock('@kbn/server-http-tools', () => ({ createServer: createServerMock, getServerOptions: getServerOptionsMock, - getListenerOptions: getListenerOptionsMock, sslSchema, SslConfig: jest.fn(), })); diff --git a/packages/kbn-health-gateway-server/src/server/server.test.ts b/packages/kbn-health-gateway-server/src/server/server.test.ts index e0a65229c3374..739bb8f0a5916 100644 --- a/packages/kbn-health-gateway-server/src/server/server.test.ts +++ b/packages/kbn-health-gateway-server/src/server/server.test.ts @@ -9,7 +9,6 @@ import { createServerMock, getServerOptionsMock, - getListenerOptionsMock, hapiStartMock, hapiStopMock, hapiRouteMock, @@ -56,9 +55,6 @@ describe('Server', () => { expect(getServerOptionsMock.mock.calls[0][0]).toEqual( expect.objectContaining({ ...mockConfig }) ); - expect(getListenerOptionsMock.mock.calls[0][0]).toEqual( - expect.objectContaining({ ...mockConfig }) - ); }); test('starts the Hapi server', async () => { diff --git a/packages/kbn-health-gateway-server/src/server/server.ts b/packages/kbn-health-gateway-server/src/server/server.ts index e75df33859981..1b679db5b9085 100644 --- a/packages/kbn-health-gateway-server/src/server/server.ts +++ b/packages/kbn-health-gateway-server/src/server/server.ts @@ -7,7 +7,7 @@ */ import type { Server as HapiServer, ServerRoute as HapiServerRoute } from '@hapi/hapi'; -import { createServer, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; +import { createServer, getServerOptions } from '@kbn/server-http-tools'; import type { IConfigService } from '@kbn/config'; import type { Logger, LoggerFactory } from '@kbn/logging'; import { ServerConfig } from './server_config'; @@ -40,7 +40,7 @@ export class Server { async start(): Promise { const serverConfig = new ServerConfig(this.config.atPathSync('server')); - this.server = createServer(getServerOptions(serverConfig), getListenerOptions(serverConfig)); + this.server = createServer(getServerOptions(serverConfig)); await this.server.start(); this.log.info(`Server running on ${this.server.info.uri}`); diff --git a/packages/kbn-server-http-tools/index.ts b/packages/kbn-server-http-tools/index.ts index a572cc6ab0832..7efa00c677015 100644 --- a/packages/kbn-server-http-tools/index.ts +++ b/packages/kbn-server-http-tools/index.ts @@ -9,8 +9,9 @@ export type { IHttpConfig, ISslConfig, ICorsConfig } from './src/types'; export { createServer } from './src/create_server'; export { defaultValidationErrorHandler } from './src/default_validation_error_handler'; -export { getListenerOptions } from './src/get_listener_options'; -export { getServerOptions, getServerTLSOptions } from './src/get_server_options'; +export { getServerListener } from './src/get_listener'; +export { getServerOptions } from './src/get_server_options'; +export { getServerTLSOptions } from './src/get_tls_options'; export { getRequestId } from './src/get_request_id'; export { setTlsConfig } from './src/set_tls_config'; export { sslSchema, SslConfig } from './src/ssl'; diff --git a/packages/kbn-server-http-tools/src/create_server.ts b/packages/kbn-server-http-tools/src/create_server.ts index 4752e342d5d3e..b57750ffaf538 100644 --- a/packages/kbn-server-http-tools/src/create_server.ts +++ b/packages/kbn-server-http-tools/src/create_server.ts @@ -7,23 +7,7 @@ */ import { Server, ServerOptions } from '@hapi/hapi'; -import { ListenerOptions } from './get_listener_options'; -export function createServer(serverOptions: ServerOptions, listenerOptions: ListenerOptions) { - const server = new Server(serverOptions); - - server.listener.keepAliveTimeout = listenerOptions.keepaliveTimeout; - server.listener.setTimeout(listenerOptions.socketTimeout); - server.listener.on('timeout', (socket) => { - socket.destroy(); - }); - server.listener.on('clientError', (err, socket) => { - if (socket.writable) { - socket.end(Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')); - } else { - socket.destroy(err); - } - }); - - return server; +export function createServer(serverOptions: ServerOptions) { + return new Server(serverOptions); } diff --git a/packages/kbn-server-http-tools/src/get_listener.test.mocks.ts b/packages/kbn-server-http-tools/src/get_listener.test.mocks.ts new file mode 100644 index 0000000000000..1fab2d9191367 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_listener.test.mocks.ts @@ -0,0 +1,47 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const getServerTLSOptionsMock = jest.fn(); + +jest.doMock('./get_tls_options', () => { + const actual = jest.requireActual('./get_tls_options'); + return { + ...actual, + getServerTLSOptions: getServerTLSOptionsMock, + }; +}); + +export const createHttpServerMock = jest.fn(() => { + return { + on: jest.fn(), + setTimeout: jest.fn(), + }; +}); + +jest.doMock('http', () => { + const actual = jest.requireActual('http'); + return { + ...actual, + createServer: createHttpServerMock, + }; +}); + +export const createHttpsServerMock = jest.fn(() => { + return { + on: jest.fn(), + setTimeout: jest.fn(), + }; +}); + +jest.doMock('https', () => { + const actual = jest.requireActual('https'); + return { + ...actual, + createServer: createHttpsServerMock, + }; +}); diff --git a/packages/kbn-server-http-tools/src/get_listener.test.ts b/packages/kbn-server-http-tools/src/get_listener.test.ts new file mode 100644 index 0000000000000..21e0a93763490 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_listener.test.ts @@ -0,0 +1,139 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + getServerTLSOptionsMock, + createHttpServerMock, + createHttpsServerMock, +} from './get_listener.test.mocks'; +import moment from 'moment'; +import { ByteSizeValue } from '@kbn/config-schema'; +import type { IHttpConfig } from './types'; +import { getServerListener } from './get_listener'; + +const createConfig = (parts: Partial): IHttpConfig => ({ + host: 'localhost', + port: 5601, + socketTimeout: 120000, + keepaliveTimeout: 120000, + payloadTimeout: 20000, + shutdownTimeout: moment.duration(30, 'seconds'), + maxPayload: ByteSizeValue.parse('1048576b'), + ...parts, + cors: { + enabled: false, + allowCredentials: false, + allowOrigin: ['*'], + ...parts.cors, + }, + ssl: { + enabled: false, + ...parts.ssl, + }, + restrictInternalApis: false, +}); + +describe('getServerListener', () => { + beforeEach(() => { + getServerTLSOptionsMock.mockReset(); + createHttpServerMock.mockClear(); + createHttpsServerMock.mockClear(); + }); + + describe('when TLS is enabled', () => { + it('calls getServerTLSOptions with the correct parameters', () => { + const config = createConfig({ ssl: { enabled: true } }); + + getServerListener(config); + + expect(getServerTLSOptionsMock).toHaveBeenCalledTimes(1); + expect(getServerTLSOptionsMock).toHaveBeenCalledWith(config.ssl); + }); + + it('calls https.createServer with the correct parameters', () => { + const config = createConfig({ ssl: { enabled: true } }); + + getServerTLSOptionsMock.mockReturnValue({ stub: true }); + + getServerListener(config); + + expect(createHttpsServerMock).toHaveBeenCalledTimes(1); + expect(createHttpsServerMock).toHaveBeenCalledWith({ + stub: true, + keepAliveTimeout: config.keepaliveTimeout, + }); + }); + + it('properly configures the listener', () => { + const config = createConfig({ ssl: { enabled: true } }); + const server = getServerListener(config); + + expect(server.setTimeout).toHaveBeenCalledTimes(1); + expect(server.setTimeout).toHaveBeenCalledWith(config.socketTimeout); + + expect(server.on).toHaveBeenCalledTimes(2); + expect(server.on).toHaveBeenCalledWith('clientError', expect.any(Function)); + expect(server.on).toHaveBeenCalledWith('timeout', expect.any(Function)); + }); + + it('returns the https server', () => { + const config = createConfig({ ssl: { enabled: true } }); + + const server = getServerListener(config); + + const expectedServer = createHttpsServerMock.mock.results[0].value; + + expect(server).toBe(expectedServer); + }); + }); + + describe('when TLS is disabled', () => { + it('does not call getServerTLSOptions', () => { + const config = createConfig({ ssl: { enabled: false } }); + + getServerListener(config); + + expect(getServerTLSOptionsMock).not.toHaveBeenCalled(); + }); + + it('calls http.createServer with the correct parameters', () => { + const config = createConfig({ ssl: { enabled: false } }); + + getServerTLSOptionsMock.mockReturnValue({ stub: true }); + + getServerListener(config); + + expect(createHttpServerMock).toHaveBeenCalledTimes(1); + expect(createHttpServerMock).toHaveBeenCalledWith({ + keepAliveTimeout: config.keepaliveTimeout, + }); + }); + + it('properly configures the listener', () => { + const config = createConfig({ ssl: { enabled: false } }); + const server = getServerListener(config); + + expect(server.setTimeout).toHaveBeenCalledTimes(1); + expect(server.setTimeout).toHaveBeenCalledWith(config.socketTimeout); + + expect(server.on).toHaveBeenCalledTimes(2); + expect(server.on).toHaveBeenCalledWith('clientError', expect.any(Function)); + expect(server.on).toHaveBeenCalledWith('timeout', expect.any(Function)); + }); + + it('returns the http server', () => { + const config = createConfig({ ssl: { enabled: false } }); + + const server = getServerListener(config); + + const expectedServer = createHttpServerMock.mock.results[0].value; + + expect(server).toBe(expectedServer); + }); + }); +}); diff --git a/packages/kbn-server-http-tools/src/get_listener.ts b/packages/kbn-server-http-tools/src/get_listener.ts new file mode 100644 index 0000000000000..f1dbe3de753fa --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_listener.ts @@ -0,0 +1,54 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import http from 'http'; +import https from 'https'; +import { getServerTLSOptions } from './get_tls_options'; +import type { IHttpConfig, ServerListener } from './types'; + +interface GetServerListenerOptions { + configureTLS?: boolean; +} + +export function getServerListener( + config: IHttpConfig, + options: GetServerListenerOptions = {} +): ServerListener { + return configureHttp1Listener(config, options); +} + +const configureHttp1Listener = ( + config: IHttpConfig, + { configureTLS = true }: GetServerListenerOptions = {} +): ServerListener => { + const useTLS = configureTLS && config.ssl.enabled; + const tlsOptions = useTLS ? getServerTLSOptions(config.ssl) : undefined; + + const listener = useTLS + ? https.createServer({ + ...tlsOptions, + keepAliveTimeout: config.keepaliveTimeout, + }) + : http.createServer({ + keepAliveTimeout: config.keepaliveTimeout, + }); + + listener.setTimeout(config.socketTimeout); + listener.on('timeout', (socket) => { + socket.destroy(); + }); + listener.on('clientError', (err, socket) => { + if (socket.writable) { + socket.end(Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')); + } else { + socket.destroy(err); + } + }); + + return listener; +}; diff --git a/packages/kbn-server-http-tools/src/get_listener_options.ts b/packages/kbn-server-http-tools/src/get_server_options.test.mocks.ts similarity index 55% rename from packages/kbn-server-http-tools/src/get_listener_options.ts rename to packages/kbn-server-http-tools/src/get_server_options.test.mocks.ts index 00884312b599f..32d808f264436 100644 --- a/packages/kbn-server-http-tools/src/get_listener_options.ts +++ b/packages/kbn-server-http-tools/src/get_server_options.test.mocks.ts @@ -6,16 +6,12 @@ * Side Public License, v 1. */ -import { IHttpConfig } from './types'; +export const getServerListenerMock = jest.fn(); -export interface ListenerOptions { - keepaliveTimeout: number; - socketTimeout: number; -} - -export function getListenerOptions(config: IHttpConfig): ListenerOptions { +jest.doMock('./get_listener', () => { + const actual = jest.requireActual('./get_listener'); return { - keepaliveTimeout: config.keepaliveTimeout, - socketTimeout: config.socketTimeout, + ...actual, + getServerListener: getServerListenerMock, }; -} +}); diff --git a/packages/kbn-server-http-tools/src/get_server_options.test.ts b/packages/kbn-server-http-tools/src/get_server_options.test.ts index 2d8f78a1405ac..00c140f46f6c7 100644 --- a/packages/kbn-server-http-tools/src/get_server_options.test.ts +++ b/packages/kbn-server-http-tools/src/get_server_options.test.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ +import { getServerListenerMock } from './get_server_options.test.mocks'; import moment from 'moment'; import { ByteSizeValue } from '@kbn/config-schema'; +import type { IHttpConfig } from './types'; import { getServerOptions } from './get_server_options'; -import { IHttpConfig } from './types'; jest.mock('fs', () => { const original = jest.requireActual('fs'); @@ -43,69 +44,42 @@ const createConfig = (parts: Partial): IHttpConfig => ({ }); describe('getServerOptions', () => { - beforeEach(() => - jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`) - ); + beforeEach(() => { + jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`); + getServerListenerMock.mockReset(); + }); afterEach(() => { jest.clearAllMocks(); }); - it('properly configures TLS with default options', () => { - const httpConfig = createConfig({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - }, - }); + it('calls `getServerListener` to retrieve the listener that will be provided in the config', () => { + const listener = Symbol('listener'); + getServerListenerMock.mockReturnValue(listener); - expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` - Object { - "ca": undefined, - "cert": "some-certificate-path", - "ciphers": undefined, - "honorCipherOrder": true, - "key": "some-key-path", - "passphrase": undefined, - "rejectUnauthorized": undefined, - "requestCert": undefined, - "secureOptions": undefined, - } - `); - }); + const httpConfig = createConfig({}); + const serverOptions = getServerOptions(httpConfig, { configureTLS: true }); - it('properly configures TLS with client authentication', () => { - const httpConfig = createConfig({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - certificateAuthorities: ['ca-1', 'ca-2'], - cipherSuites: ['suite-a', 'suite-b'], - keyPassphrase: 'passPhrase', - rejectUnauthorized: true, - requestCert: true, - getSecureOptions: () => 42, - }, - }); + expect(getServerListenerMock).toHaveBeenCalledTimes(1); + expect(getServerListenerMock).toHaveBeenCalledWith(httpConfig, { configureTLS: true }); + + expect(serverOptions.listener).toBe(listener); + }); - expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` - Object { - "ca": Array [ - "ca-1", - "ca-2", - ], - "cert": "some-certificate-path", - "ciphers": "suite-a:suite-b", - "honorCipherOrder": true, - "key": "some-key-path", - "passphrase": "passPhrase", - "rejectUnauthorized": true, - "requestCert": true, - "secureOptions": 42, - } - `); + it('properly configures the tls option depending on the config and the configureTLS flag', () => { + expect( + getServerOptions(createConfig({ ssl: { enabled: true } }), { configureTLS: true }).tls + ).toBe(true); + expect(getServerOptions(createConfig({ ssl: { enabled: true } }), {}).tls).toBe(true); + expect( + getServerOptions(createConfig({ ssl: { enabled: true } }), { configureTLS: false }).tls + ).toBe(false); + expect( + getServerOptions(createConfig({ ssl: { enabled: false } }), { configureTLS: true }).tls + ).toBe(false); + expect( + getServerOptions(createConfig({ ssl: { enabled: false } }), { configureTLS: false }).tls + ).toBe(false); }); it('properly configures CORS when cors enabled', () => { diff --git a/packages/kbn-server-http-tools/src/get_server_options.ts b/packages/kbn-server-http-tools/src/get_server_options.ts index 37a8f5f69cc2b..fe0a669fd62f5 100644 --- a/packages/kbn-server-http-tools/src/get_server_options.ts +++ b/packages/kbn-server-http-tools/src/get_server_options.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { RouteOptionsCors, ServerOptions } from '@hapi/hapi'; -import { ServerOptions as TLSOptions } from 'https'; +import type { RouteOptionsCors, ServerOptions } from '@hapi/hapi'; +import type { IHttpConfig } from './types'; import { defaultValidationErrorHandler } from './default_validation_error_handler'; -import { IHttpConfig, ISslConfig } from './types'; +import { getServerListener } from './get_listener'; const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf']; @@ -27,6 +27,10 @@ export function getServerOptions(config: IHttpConfig, { configureTLS = true } = const options: ServerOptions = { host: config.host, port: config.port, + // manually configuring the listener + listener: getServerListener(config, { configureTLS }), + // must set to true when manually passing a TLS listener, false otherwise + tls: configureTLS && config.ssl.enabled, routes: { cache: { privacy: 'private', @@ -51,31 +55,5 @@ export function getServerOptions(config: IHttpConfig, { configureTLS = true } = }, }; - if (configureTLS) { - options.tls = getServerTLSOptions(config.ssl); - } - return options; } - -/** - * Converts Kibana `SslConfig` into `TLSOptions` that are accepted by the Hapi server, - * and by https.Server.setSecureContext() - */ -export function getServerTLSOptions(ssl: ISslConfig): TLSOptions | undefined { - if (!ssl.enabled) { - return undefined; - } - return { - ca: ssl.certificateAuthorities, - cert: ssl.certificate, - ciphers: ssl.cipherSuites?.join(':'), - // We use the server's cipher order rather than the client's to prevent the BEAST attack. - honorCipherOrder: true, - key: ssl.key, - passphrase: ssl.keyPassphrase, - secureOptions: ssl.getSecureOptions ? ssl.getSecureOptions() : undefined, - requestCert: ssl.requestCert, - rejectUnauthorized: ssl.rejectUnauthorized, - }; -} diff --git a/packages/kbn-server-http-tools/src/get_tls_options.test.ts b/packages/kbn-server-http-tools/src/get_tls_options.test.ts new file mode 100644 index 0000000000000..0a50209db50c9 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_tls_options.test.ts @@ -0,0 +1,110 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import { ByteSizeValue } from '@kbn/config-schema'; +import type { IHttpConfig } from './types'; +import { getServerTLSOptions } from './get_tls_options'; + +jest.mock('fs', () => { + const original = jest.requireActual('fs'); + return { + // Hapi Inert patches native methods + ...original, + readFileSync: jest.fn(), + }; +}); + +const createConfig = (parts: Partial): IHttpConfig => ({ + host: 'localhost', + port: 5601, + socketTimeout: 120000, + keepaliveTimeout: 120000, + payloadTimeout: 20000, + shutdownTimeout: moment.duration(30, 'seconds'), + maxPayload: ByteSizeValue.parse('1048576b'), + ...parts, + cors: { + enabled: false, + allowCredentials: false, + allowOrigin: ['*'], + ...parts.cors, + }, + ssl: { + enabled: false, + ...parts.ssl, + }, + restrictInternalApis: false, +}); + +describe('getServerTLSOptions', () => { + beforeEach(() => + jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`) + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('properly configures TLS with default options', () => { + const httpConfig = createConfig({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + }, + }); + + expect(getServerTLSOptions(httpConfig.ssl)).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "cert": "some-certificate-path", + "ciphers": undefined, + "honorCipherOrder": true, + "key": "some-key-path", + "passphrase": undefined, + "rejectUnauthorized": undefined, + "requestCert": undefined, + "secureOptions": undefined, + } + `); + }); + + it('properly configures TLS with client authentication', () => { + const httpConfig = createConfig({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + certificateAuthorities: ['ca-1', 'ca-2'], + cipherSuites: ['suite-a', 'suite-b'], + keyPassphrase: 'passPhrase', + rejectUnauthorized: true, + requestCert: true, + getSecureOptions: () => 42, + }, + }); + + expect(getServerTLSOptions(httpConfig.ssl)).toMatchInlineSnapshot(` + Object { + "ca": Array [ + "ca-1", + "ca-2", + ], + "cert": "some-certificate-path", + "ciphers": "suite-a:suite-b", + "honorCipherOrder": true, + "key": "some-key-path", + "passphrase": "passPhrase", + "rejectUnauthorized": true, + "requestCert": true, + "secureOptions": 42, + } + `); + }); +}); diff --git a/packages/kbn-server-http-tools/src/get_tls_options.ts b/packages/kbn-server-http-tools/src/get_tls_options.ts new file mode 100644 index 0000000000000..eb55327cef326 --- /dev/null +++ b/packages/kbn-server-http-tools/src/get_tls_options.ts @@ -0,0 +1,32 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ServerOptions as TLSOptions } from 'https'; +import { ISslConfig } from './types'; + +/** + * Converts Kibana `SslConfig` into `TLSOptions` that are accepted by the Hapi server, + * and by https.Server.setSecureContext() + */ +export function getServerTLSOptions(ssl: ISslConfig): TLSOptions | undefined { + if (!ssl.enabled) { + return undefined; + } + return { + ca: ssl.certificateAuthorities, + cert: ssl.certificate, + ciphers: ssl.cipherSuites?.join(':'), + // We use the server's cipher order rather than the client's to prevent the BEAST attack. + honorCipherOrder: true, + key: ssl.key, + passphrase: ssl.keyPassphrase, + secureOptions: ssl.getSecureOptions ? ssl.getSecureOptions() : undefined, + requestCert: ssl.requestCert, + rejectUnauthorized: ssl.rejectUnauthorized, + }; +} diff --git a/packages/kbn-server-http-tools/src/set_tls_config.test.mocks.ts b/packages/kbn-server-http-tools/src/set_tls_config.test.mocks.ts index 4b93301b334e5..597ab7d176c8e 100644 --- a/packages/kbn-server-http-tools/src/set_tls_config.test.mocks.ts +++ b/packages/kbn-server-http-tools/src/set_tls_config.test.mocks.ts @@ -8,8 +8,8 @@ export const getServerTLSOptionsMock = jest.fn(); -jest.doMock('./get_server_options', () => { - const actual = jest.requireActual('./get_server_options'); +jest.doMock('./get_tls_options', () => { + const actual = jest.requireActual('./get_tls_options'); return { ...actual, getServerTLSOptions: getServerTLSOptionsMock, diff --git a/packages/kbn-server-http-tools/src/set_tls_config.ts b/packages/kbn-server-http-tools/src/set_tls_config.ts index 1f2e1d70fa126..6b0cd35f067ea 100644 --- a/packages/kbn-server-http-tools/src/set_tls_config.ts +++ b/packages/kbn-server-http-tools/src/set_tls_config.ts @@ -7,18 +7,17 @@ */ import type { Server as HapiServer } from '@hapi/hapi'; -import type { Server as HttpServer } from 'http'; import type { Server as TlsServer } from 'https'; -import type { ISslConfig } from './types'; -import { getServerTLSOptions } from './get_server_options'; +import type { ISslConfig, ServerListener } from './types'; +import { getServerTLSOptions } from './get_tls_options'; -function isServerTLS(server: HttpServer): server is TlsServer { +function isTLSListener(server: ServerListener): server is TlsServer { return 'setSecureContext' in server; } export const setTlsConfig = (hapiServer: HapiServer, sslConfig: ISslConfig) => { const server = hapiServer.listener; - if (!isServerTLS(server)) { + if (!isTLSListener(server)) { throw new Error('tried to set TLS config on a non-TLS http server'); } const tlsOptions = getServerTLSOptions(sslConfig); diff --git a/packages/kbn-server-http-tools/src/types.ts b/packages/kbn-server-http-tools/src/types.ts index 693cb6feb46fe..88533162b2a32 100644 --- a/packages/kbn-server-http-tools/src/types.ts +++ b/packages/kbn-server-http-tools/src/types.ts @@ -6,9 +6,19 @@ * Side Public License, v 1. */ +import type { Server as HttpServer } from 'http'; +import type { Server as HttpsServer } from 'https'; import { ByteSizeValue } from '@kbn/config-schema'; import type { Duration } from 'moment'; +/** + * Composite type of all possible kind of Listener types. + * + * Unfortunately, there's no real common interface between all those concrete classes, + * as `net.Server` and `tls.Server` don't list all the APIs we're using (such as event binding) + */ +export type ServerListener = HttpServer | HttpsServer; + export interface IHttpConfig { host: string; port: number; diff --git a/src/core/server/integration_tests/http/set_tls_config.test.ts b/src/core/server/integration_tests/http/set_tls_config.test.ts index 6c198d820670f..b809a32075733 100644 --- a/src/core/server/integration_tests/http/set_tls_config.test.ts +++ b/src/core/server/integration_tests/http/set_tls_config.test.ts @@ -8,12 +8,7 @@ import supertest from 'supertest'; import { KBN_CERT_PATH, KBN_KEY_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils'; -import { - createServer, - getListenerOptions, - getServerOptions, - setTlsConfig, -} from '@kbn/server-http-tools'; +import { createServer, getServerOptions, setTlsConfig } from '@kbn/server-http-tools'; import { HttpConfig, config as httpConfig, @@ -47,8 +42,7 @@ describe('setTlsConfig', () => { const firstConfig = new HttpConfig(rawHttpConfig, CSP_CONFIG, EXTERNAL_URL_CONFIG); const serverOptions = getServerOptions(firstConfig); - const listenerOptions = getListenerOptions(firstConfig); - const server = createServer(serverOptions, listenerOptions); + const server = createServer(serverOptions); server.route({ method: 'GET',