Skip to content

Commit

Permalink
Add TLS client authentication support. (#43090)
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin authored Aug 14, 2019
1 parent e2e1941 commit 6be8b32
Show file tree
Hide file tree
Showing 12 changed files with 289 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) &gt; [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`<!-- -->.

<b>Signature:</b>

```typescript
readonly authorizationError?: Error;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) &gt; [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`<!-- -->.

<b>Signature:</b>

```typescript
readonly authorized?: boolean;
```
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ A tiny abstraction for TCP socket.
export interface IKibanaSocket
```

## Properties

| Property | Type | Description |
| --- | --- | --- |
| [authorizationError](./kibana-plugin-server.ikibanasocket.authorizationerror.md) | <code>Error</code> | The reason why the peer's certificate has not been verified. This property becomes available only when <code>authorized</code> is <code>false</code>. |
| [authorized](./kibana-plugin-server.ikibanasocket.authorized.md) | <code>boolean</code> | Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS isn't used the value is <code>undefined</code>. |

## Methods

| Method | Description |
Expand Down
4 changes: 4 additions & 0 deletions docs/setup/settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 94 additions & 1 deletion src/core/server/http/http_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
});
});
77 changes: 75 additions & 2 deletions src/core/server/http/http_tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,6 +47,8 @@ const emptyOutput = {
},
};

afterEach(() => jest.clearAllMocks());

describe('defaultValidationErrorHandler', () => {
it('formats value validation errors correctly', () => {
expect.assertions(1);
Expand Down Expand Up @@ -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,
}
`);
});
});
1 change: 1 addition & 0 deletions src/core/server/http/http_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions src/core/server/http/router/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
22 changes: 21 additions & 1 deletion src/core/server/http/router/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 6be8b32

Please sign in to comment.