From 0b54b32fb9944d532fd83b92430ceec1667946ca Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 14 Aug 2019 22:57:54 +0200 Subject: [PATCH] Add TLS client authentication support. (#43090) --- ...server.ikibanasocket.authorizationerror.md | 13 +++ ...-plugin-server.ikibanasocket.authorized.md | 13 +++ .../kibana-plugin-server.ikibanasocket.md | 7 ++ docs/setup/settings.asciidoc | 4 + .../__snapshots__/http_config.test.ts.snap | 1 + src/core/server/http/http_config.test.ts | 95 ++++++++++++++++++- src/core/server/http/http_tools.test.ts | 77 ++++++++++++++- src/core/server/http/http_tools.ts | 1 + src/core/server/http/router/socket.test.ts | 46 +++++++++ src/core/server/http/router/socket.ts | 22 ++++- src/core/server/http/ssl_config.ts | 15 ++- src/core/server/server.api.md | 2 + 12 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.ikibanasocket.authorizationerror.md create mode 100644 docs/development/core/server/kibana-plugin-server.ikibanasocket.authorized.md diff --git a/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorizationerror.md b/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorizationerror.md new file mode 100644 index 0000000000000..0629b8e2b9ade --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorizationerror.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) > [authorizationError](./kibana-plugin-server.ikibanasocket.authorizationerror.md) + +## IKibanaSocket.authorizationError property + +The reason why the peer's certificate has not been verified. This property becomes available only when `authorized` is `false`. + +Signature: + +```typescript +readonly authorizationError?: Error; +``` diff --git a/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorized.md b/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorized.md new file mode 100644 index 0000000000000..abb68f8e8f0e0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.ikibanasocket.authorized.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) > [authorized](./kibana-plugin-server.ikibanasocket.authorized.md) + +## IKibanaSocket.authorized property + +Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS isn't used the value is `undefined`. + +Signature: + +```typescript +readonly authorized?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.ikibanasocket.md b/docs/development/core/server/kibana-plugin-server.ikibanasocket.md index 129a3b1d2311a..d100b1eb2bcd9 100644 --- a/docs/development/core/server/kibana-plugin-server.ikibanasocket.md +++ b/docs/development/core/server/kibana-plugin-server.ikibanasocket.md @@ -12,6 +12,13 @@ A tiny abstraction for TCP socket. export interface IKibanaSocket ``` +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [authorizationError](./kibana-plugin-server.ikibanasocket.authorizationerror.md) | Error | The reason why the peer's certificate has not been verified. This property becomes available only when authorized is false. | +| [authorized](./kibana-plugin-server.ikibanasocket.authorized.md) | boolean | Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS isn't used the value is undefined. | + ## Methods | Method | Description | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index ac78d03d18043..80a7f14c73ec0 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -296,6 +296,10 @@ files that should be trusted. Details on the format, and the valid options, are available via the https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenSSL cipher list format documentation]. +`server.ssl.clientAuthentication:`:: *Default: none* Controls the server’s behavior in regard to requesting a certificate from client +connections. Valid values are `required`, `optional`, and `none`. `required` forces a client to present a certificate, while `optional` +requests a client certificate but the client is not required to present one. + `server.ssl.enabled:`:: *Default: "false"* Enables SSL for outgoing requests from the Kibana server to the browser. When set to `true`, `server.ssl.certificate` and `server.ssl.key` are required. diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index bdb65809d811c..57d9db5e8c1e4 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -45,6 +45,7 @@ Object { "!SRP", "!CAMELLIA", ], + "clientAuthentication": "none", "enabled": false, "supportedProtocols": Array [ "TLSv1.1", diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 11eec99a6f014..2b627c265dbba 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -17,7 +17,9 @@ * under the License. */ -import { config } from '.'; +import { config, HttpConfig } from '.'; +import { Env } from '../config'; +import { getEnvOptions } from '../config/__mocks__/env'; test('has defaults for config', () => { const httpSchema = config.schema; @@ -111,6 +113,46 @@ describe('with TLS', () => { expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); }); + test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => { + const httpSchema = config.schema; + const obj = { + port: 1234, + ssl: { + enabled: false, + clientAuthentication: 'optional', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: must enable ssl to use [clientAuthentication]"` + ); + }); + + test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => { + const httpSchema = config.schema; + const obj = { + port: 1234, + ssl: { + enabled: false, + clientAuthentication: 'required', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: must enable ssl to use [clientAuthentication]"` + ); + }); + + test('can specify `none` for [clientAuthentication] if ssl is not enabled', () => { + const obj = { + ssl: { + enabled: false, + clientAuthentication: 'none', + }, + }; + + const configValue = config.schema.validate(obj); + expect(configValue.ssl.clientAuthentication).toBe('none'); + }); + test('can specify single `certificateAuthority` as a string', () => { const obj = { ssl: { @@ -202,4 +244,55 @@ describe('with TLS', () => { httpSchema.validate(allKnownWithOneUnknownProtocols) ).toThrowErrorMatchingSnapshot(); }); + + test('HttpConfig instance should properly interpret `none` client authentication', () => { + const httpConfig = new HttpConfig( + config.schema.validate({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'none', + }, + }), + Env.createDefault(getEnvOptions()) + ); + + expect(httpConfig.ssl.requestCert).toBe(false); + expect(httpConfig.ssl.rejectUnauthorized).toBe(false); + }); + + test('HttpConfig instance should properly interpret `optional` client authentication', () => { + const httpConfig = new HttpConfig( + config.schema.validate({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'optional', + }, + }), + Env.createDefault(getEnvOptions()) + ); + + expect(httpConfig.ssl.requestCert).toBe(true); + expect(httpConfig.ssl.rejectUnauthorized).toBe(false); + }); + + test('HttpConfig instance should properly interpret `required` client authentication', () => { + const httpConfig = new HttpConfig( + config.schema.validate({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'required', + }, + }), + Env.createDefault(getEnvOptions()) + ); + + expect(httpConfig.ssl.requestCert).toBe(true); + expect(httpConfig.ssl.rejectUnauthorized).toBe(true); + }); }); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 7cda25d957b42..f31e5ffff2358 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -17,16 +17,22 @@ * under the License. */ +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + import supertest from 'supertest'; import { Request, ResponseToolkit } from 'hapi'; import Joi from 'joi'; -import { defaultValidationErrorHandler, HapiValidationError } from './http_tools'; +import { defaultValidationErrorHandler, HapiValidationError, getServerOptions } from './http_tools'; import { HttpServer } from './http_server'; -import { HttpConfig } from './http_config'; +import { HttpConfig, config } from './http_config'; import { Router } from './router'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { ByteSizeValue } from '@kbn/config-schema'; +import { Env } from '../config'; +import { getEnvOptions } from '../config/__mocks__/env'; const emptyOutput = { statusCode: 400, @@ -41,6 +47,8 @@ const emptyOutput = { }, }; +afterEach(() => jest.clearAllMocks()); + describe('defaultValidationErrorHandler', () => { it('formats value validation errors correctly', () => { expect.assertions(1); @@ -97,3 +105,68 @@ describe('timeouts', () => { await server.stop(); }); }); + +describe('getServerOptions', () => { + beforeEach(() => + jest.requireMock('fs').readFileSync.mockImplementation((path: string) => `content-${path}`) + ); + + it('properly configures TLS with default options', () => { + const httpConfig = new HttpConfig( + config.schema.validate({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + }, + }), + Env.createDefault(getEnvOptions()) + ); + + expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "cert": "content-some-certificate-path", + "ciphers": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA", + "honorCipherOrder": true, + "key": "content-some-key-path", + "passphrase": undefined, + "rejectUnauthorized": false, + "requestCert": false, + "secureOptions": 67108864, + } + `); + }); + + it('properly configures TLS with client authentication', () => { + const httpConfig = new HttpConfig( + config.schema.validate({ + ssl: { + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + certificateAuthorities: ['ca-1', 'ca-2'], + clientAuthentication: 'required', + }, + }), + Env.createDefault(getEnvOptions()) + ); + + expect(getServerOptions(httpConfig).tls).toMatchInlineSnapshot(` + Object { + "ca": Array [ + "content-ca-1", + "content-ca-2", + ], + "cert": "content-some-certificate-path", + "ciphers": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA", + "honorCipherOrder": true, + "key": "content-some-key-path", + "passphrase": undefined, + "rejectUnauthorized": true, + "requestCert": true, + "secureOptions": 67108864, + } + `); + }); +}); diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 2953d5272ebe9..88164a76c66f0 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -71,6 +71,7 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = { passphrase: ssl.keyPassphrase, secureOptions: ssl.getSecureOptions(), requestCert: ssl.requestCert, + rejectUnauthorized: ssl.rejectUnauthorized, }; options.tls = tlsOptions; diff --git a/src/core/server/http/router/socket.test.ts b/src/core/server/http/router/socket.test.ts index 6bd903fd2f36c..c813dcf3fc806 100644 --- a/src/core/server/http/router/socket.test.ts +++ b/src/core/server/http/router/socket.test.ts @@ -56,4 +56,50 @@ describe('KibanaSocket', () => { expect(socket.getPeerCertificate()).toBe(null); }); }); + + describe('authorized', () => { + it('returns `undefined` for net.Socket instance', () => { + const socket = new KibanaSocket(new Socket()); + + expect(socket.authorized).toBeUndefined(); + }); + + it('mirrors the value of tls.Socket.authorized', () => { + const tlsSocket = new TLSSocket(new Socket()); + + tlsSocket.authorized = true; + let socket = new KibanaSocket(tlsSocket); + expect(tlsSocket.authorized).toBe(true); + expect(socket.authorized).toBe(true); + + tlsSocket.authorized = false; + socket = new KibanaSocket(tlsSocket); + expect(tlsSocket.authorized).toBe(false); + expect(socket.authorized).toBe(false); + }); + }); + + describe('authorizationError', () => { + it('returns `undefined` for net.Socket instance', () => { + const socket = new KibanaSocket(new Socket()); + + expect(socket.authorizationError).toBeUndefined(); + }); + + it('mirrors the value of tls.Socket.authorizationError', () => { + const tlsSocket = new TLSSocket(new Socket()); + tlsSocket.authorizationError = undefined as any; + + let socket = new KibanaSocket(tlsSocket); + expect(tlsSocket.authorizationError).toBeUndefined(); + expect(socket.authorizationError).toBeUndefined(); + + const authorizationError = new Error('some error'); + tlsSocket.authorizationError = authorizationError; + socket = new KibanaSocket(tlsSocket); + + expect(tlsSocket.authorizationError).toBe(authorizationError); + expect(socket.authorizationError).toBe(authorizationError); + }); + }); }); diff --git a/src/core/server/http/router/socket.ts b/src/core/server/http/router/socket.ts index 2cdcd8f641001..83bf65a288c4b 100644 --- a/src/core/server/http/router/socket.ts +++ b/src/core/server/http/router/socket.ts @@ -37,10 +37,30 @@ export interface IKibanaSocket { * @returns An object representing the peer's certificate. */ getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate | null; + + /** + * Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS + * isn't used the value is `undefined`. + */ + readonly authorized?: boolean; + + /** + * The reason why the peer's certificate has not been verified. This property becomes available + * only when `authorized` is `false`. + */ + readonly authorizationError?: Error; } export class KibanaSocket implements IKibanaSocket { - constructor(private readonly socket: Socket) {} + readonly authorized?: boolean; + readonly authorizationError?: Error; + + constructor(private readonly socket: Socket) { + if (this.socket instanceof TLSSocket) { + this.authorized = this.socket.authorized; + this.authorizationError = this.socket.authorizationError; + } + } getPeerCertificate(detailed: true): DetailedPeerCertificate | null; getPeerCertificate(detailed: false): PeerCertificate | null; diff --git a/src/core/server/http/ssl_config.ts b/src/core/server/http/ssl_config.ts index c32b94cf26def..55d6ebff93ce7 100644 --- a/src/core/server/http/ssl_config.ts +++ b/src/core/server/http/ssl_config.ts @@ -49,13 +49,20 @@ export const sslSchema = schema.object( schema.oneOf([schema.literal('TLSv1'), schema.literal('TLSv1.1'), schema.literal('TLSv1.2')]), { defaultValue: ['TLSv1.1', 'TLSv1.2'], minSize: 1 } ), - requestCert: schema.maybe(schema.boolean({ defaultValue: false })), + clientAuthentication: schema.oneOf( + [schema.literal('none'), schema.literal('optional'), schema.literal('required')], + { defaultValue: 'none' } + ), }, { validate: ssl => { if (ssl.enabled && (!ssl.key || !ssl.certificate)) { return 'must specify [certificate] and [key] when ssl is enabled'; } + + if (!ssl.enabled && ssl.clientAuthentication !== 'none') { + return 'must enable ssl to use [clientAuthentication]'; + } }, } ); @@ -69,7 +76,8 @@ export class SslConfig { public certificate: string | undefined; public certificateAuthorities: string[] | undefined; public keyPassphrase: string | undefined; - public requestCert: boolean | undefined; + public requestCert: boolean; + public rejectUnauthorized: boolean; public cipherSuites: string[]; public supportedProtocols: string[]; @@ -86,7 +94,8 @@ export class SslConfig { this.keyPassphrase = config.keyPassphrase; this.cipherSuites = config.cipherSuites; this.supportedProtocols = config.supportedProtocols; - this.requestCert = config.requestCert; + this.requestCert = config.clientAuthentication !== 'none'; + this.rejectUnauthorized = config.clientAuthentication === 'required'; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 6597fdf0259d4..49188fbd7b663 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -261,6 +261,8 @@ export type IContextProvider, TContextName // @public export interface IKibanaSocket { + readonly authorizationError?: Error; + readonly authorized?: boolean; // (undocumented) getPeerCertificate(detailed: true): DetailedPeerCertificate | null; // (undocumented)