Skip to content

Commit

Permalink
extract http_tools to package
Browse files Browse the repository at this point in the history
  • Loading branch information
pgayvallet committed Mar 23, 2021
1 parent 7d303eb commit 7fdc347
Show file tree
Hide file tree
Showing 23 changed files with 643 additions and 476 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"@kbn/apm-utils": "link:packages/kbn-apm-utils",
"@kbn/config": "link:packages/kbn-config",
"@kbn/config-schema": "link:packages/kbn-config-schema",
"@kbn/http-tools": "link:packages/kbn-http-tools",
"@kbn/i18n": "link:packages/kbn-i18n",
"@kbn/interpreter": "link:packages/kbn-interpreter",
"@kbn/legacy-logging": "link:packages/kbn-legacy-logging",
Expand Down
3 changes: 3 additions & 0 deletions packages/kbn-http-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @kbn/utils

Shared server-side utilities shared across packages and plugins.
13 changes: 13 additions & 0 deletions packages/kbn-http-tools/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-http-tools'],
};
19 changes: 19 additions & 0 deletions packages/kbn-http-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@kbn/http-tools",
"main": "./target/index.js",
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0",
"private": true,
"scripts": {
"build": "rm -rf target && ../../node_modules/.bin/tsc",
"kbn:bootstrap": "yarn build",
"kbn:watch": "yarn build --watch"
},
"dependencies": {
"@kbn/config-schema": "link:../kbn-config-schema",
"@kbn/std": "link:../kbn-std"
},
"devDependencies": {
"@kbn/utility-types": "link:../kbn-utility-types"
}
}
29 changes: 29 additions & 0 deletions packages/kbn-http-tools/src/create_server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 { 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 Joi from 'joi';
import { Request, ResponseToolkit } from '@hapi/hapi';
import {
defaultValidationErrorHandler,
HapiValidationError,
} from './default_validation_error_handler';

const emptyOutput = {
statusCode: 400,
headers: {},
payload: {
statusCode: 400,
error: '',
validation: {
source: '',
keys: [],
},
},
};

describe('defaultValidationErrorHandler', () => {
it('formats value validation errors correctly', () => {
expect.assertions(1);
const schema = Joi.array().items(
Joi.object({
type: Joi.string().required(),
}).required()
);

const error = schema.validate([{}], { abortEarly: false }).error as HapiValidationError;

// Emulate what Hapi v17 does by default
error.output = { ...emptyOutput };
error.output.payload.validation.keys = ['0.type', ''];

try {
defaultValidationErrorHandler({} as Request, {} as ResponseToolkit, error);
} catch (err) {
// Verify the empty string gets corrected to 'value'
expect(err.output.payload.validation.keys).toEqual(['0.type', 'value']);
}
});
});
63 changes: 63 additions & 0 deletions packages/kbn-http-tools/src/default_validation_error_handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 { Lifecycle, Request, ResponseToolkit, Util } from '@hapi/hapi';
import { ValidationError } from 'joi';
import Hoek from '@hapi/hoek';

/**
* Hapi extends the ValidationError interface to add this output key with more data.
*/
export interface HapiValidationError extends ValidationError {
output: {
statusCode: number;
headers: Util.Dictionary<string | string[]>;
payload: {
statusCode: number;
error: string;
message?: string;
validation: {
source: string;
keys: string[];
};
};
};
}

/**
* Used to replicate Hapi v16 and below's validation responses. Should be used in the routes.validate.failAction key.
*/
export function defaultValidationErrorHandler(
request: Request,
h: ResponseToolkit,
err?: Error
): Lifecycle.ReturnValue {
// Newer versions of Joi don't format the key for missing params the same way. This shim
// provides backwards compatibility. Unfortunately, Joi doesn't export it's own Error class
// in JS so we have to rely on the `name` key before we can cast it.
//
// The Hapi code we're 'overwriting' can be found here:
// https://github.com/hapijs/hapi/blob/master/lib/validation.js#L102
if (err && err.name === 'ValidationError' && err.hasOwnProperty('output')) {
const validationError: HapiValidationError = err as HapiValidationError;
const validationKeys: string[] = [];

validationError.details.forEach((detail) => {
if (detail.path.length > 0) {
validationKeys.push(Hoek.escapeHtml(detail.path.join('.')));
} else {
// If no path, use the value sigil to signal the entire value had an issue.
validationKeys.push('value');
}
});

validationError.output.payload.validation.keys = validationKeys;
}

throw err;
}
21 changes: 21 additions & 0 deletions packages/kbn-http-tools/src/get_listener_options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 { IHttpConfig } from './types';

export interface ListenerOptions {
keepaliveTimeout: number;
socketTimeout: number;
}

export function getListenerOptions(config: IHttpConfig): ListenerOptions {
return {
keepaliveTimeout: config.keepaliveTimeout,
socketTimeout: config.socketTimeout,
};
}
85 changes: 85 additions & 0 deletions packages/kbn-http-tools/src/get_request_id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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 { getRequestId } from './get_request_id';

jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'),
}));

describe('getRequestId', () => {
describe('when allowFromAnyIp is true', () => {
it('generates a UUID if no x-opaque-id header is present', () => {
const request = {
headers: {},
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
} as any;
expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual(
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
);
});

it('uses x-opaque-id header value if present', () => {
const request = {
headers: {
'x-opaque-id': 'id from header',
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
},
} as any;
expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual(
'id from header'
);
});
});

describe('when allowFromAnyIp is false', () => {
describe('and ipAllowlist is empty', () => {
it('generates a UUID even if x-opaque-id header is present', () => {
const request = {
headers: { 'x-opaque-id': 'id from header' },
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
} as any;
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: [] })).toEqual(
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
);
});
});

describe('and ipAllowlist is not empty', () => {
it('uses x-opaque-id header if request comes from trusted IP address', () => {
const request = {
headers: { 'x-opaque-id': 'id from header' },
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
} as any;
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
'id from header'
);
});

it('generates a UUID if request comes from untrusted IP address', () => {
const request = {
headers: { 'x-opaque-id': 'id from header' },
raw: { req: { socket: { remoteAddress: '5.5.5.5' } } },
} as any;
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
);
});

it('generates UUID if request comes from trusted IP address but no x-opaque-id header is present', () => {
const request = {
headers: {},
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
} as any;
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
);
});
});
});
});
22 changes: 22 additions & 0 deletions packages/kbn-http-tools/src/get_request_id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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 { Request } from '@hapi/hapi';
import uuid from 'uuid';

export function getRequestId(
request: Request,
options: { allowFromAnyIp: boolean; ipAllowlist: string[] }
): string {
return options.allowFromAnyIp ||
// socket may be undefined in integration tests that connect via the http listener directly
(request.raw.req.socket?.remoteAddress &&
options.ipAllowlist.includes(request.raw.req.socket.remoteAddress))
? request.headers['x-opaque-id'] ?? uuid.v4()
: uuid.v4();
}
Loading

0 comments on commit 7fdc347

Please sign in to comment.