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 336adea0758ab..395f49358dc6b 100644
--- a/docs/setup/settings.asciidoc
+++ b/docs/setup/settings.asciidoc
@@ -295,6 +295,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)