Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TLS client authentication support. #43090

Merged
merged 3 commits into from
Aug 14, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: just a bunch of tests to only test functionality I'm introducing...

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