diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md index 5a6103dfc57d4c..fee6124f8d8662 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md @@ -19,5 +19,6 @@ export interface RouteConfigOptions | [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | boolean | 'optional' | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.Defaults to true if an auth mechanism is registered. | | [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | +| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | number | Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes | | [xsrfRequired](./kibana-plugin-core-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md new file mode 100644 index 00000000000000..479fcf883ec4d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) > [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) + +## RouteConfigOptions.timeout property + +Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes + +Signature: + +```typescript +timeout?: number; +``` diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 601eba835a54e8..007d75a69b955a 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -992,6 +992,133 @@ describe('body options', () => { }); }); +describe('timeout options', () => { + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a POST', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.post( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a GET', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.get( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).get('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a DELETE', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.delete( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).delete('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PUT', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.put( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).put('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PATCH', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.patch( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).patch('/').expect(200, { + timeout: 300000, + }); + }); +}); + test('should return a stream in the body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 9c16162d693348..4b70f58deba99e 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -161,8 +161,10 @@ export class HttpServer { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {} } = route.options; + const { authRequired, tags, body = {}, timeout } = route.options; const { accepts: allow, maxBytes, output, parse } = body; + // Hapi does not allow timeouts on payloads to be specified for 'head' or 'get' requests + const payloadTimeout = isSafeMethod(route.method) || timeout == null ? undefined : timeout; const kibanaRouteState: KibanaRouteState = { xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), @@ -181,9 +183,23 @@ export class HttpServer { // validation applied in ./http_tools#getServerOptions // (All NP routes are already required to specify their own validation in order to access the payload) validate, - payload: [allow, maxBytes, output, parse].some((v) => typeof v !== 'undefined') - ? { allow, maxBytes, output, parse } + payload: [allow, maxBytes, output, parse, payloadTimeout].some( + (v) => typeof v !== 'undefined' + ) + ? { + allow, + maxBytes, + output, + parse, + timeout: payloadTimeout, + } : undefined, + timeout: + timeout != null + ? { + socket: timeout + 1, // Hapi server requires the socket to be greater than payload settings so we add 1 millisecond + } + : undefined, }, }); } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index bb36fefa96611e..434e22e3cf6f5b 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -302,6 +302,130 @@ describe('Options', () => { }); }); }); + + describe('timeout', () => { + it('should timeout if configured with a small timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.post({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).post('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.put({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).put('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).put('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.delete({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).delete('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).delete('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + // Note: There is a bug within Hapi Server where it cannot set the payload timeout for a GET call but it also cannot configure a timeout less than the payload body + // so the least amount of possible time to configure the timeout is 10 seconds. + { path: '/a', validate: false, options: { timeout: 100000 } }, + async (context, req, res) => { + // Cause a wait of 20 seconds to cause the socket hangup + await new Promise((resolve) => setTimeout(resolve, 200000)); + return res.ok({}); + } + ); + router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).get('/b').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).post('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + + await supertest(innerServer.listener).put('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).delete('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).get('/a').expect(200, {}); + }); + }); }); describe('Cache-Control', () => { diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index fefd75ad9710e9..0e73431fe7c6d7 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -197,12 +197,14 @@ export class KibanaRequest< private getRouteInfo(request: Request): KibanaRequestRoute { const method = request.method as Method; const { parse, maxBytes, allow, output } = request.route.settings.payload || {}; + const timeout = request.route.settings.timeout?.socket; const options = ({ authRequired: this.getAuthRequired(request), // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], + timeout: typeof timeout === 'number' ? timeout - 1 : undefined, // We are forced to have the timeout be 1 millisecond greater than the server and payload so we subtract one here to give the user consist settings body: isSafeMethod(method) ? undefined : { diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 9789d266587afc..676c494bec522d 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -144,6 +144,12 @@ export interface RouteConfigOptions { * Additional body options {@link RouteConfigOptionsBody}. */ body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; + + /** + * Timeouts for processing durations. Response timeout is in milliseconds. + * Default value: 2 minutes + */ + timeout?: number; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bb4f2f30ac18f1..c94151f8cee179 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1859,6 +1859,7 @@ export interface RouteConfigOptions { authRequired?: boolean | 'optional'; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; + timeout?: number; xsrfRequired?: Method extends 'get' ? never : boolean; } diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 22706890e20209..b7609b5a3602a5 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EntriesArray } from './schemas/types'; +import moment from 'moment'; +import { EntriesArray } from './schemas/types'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; export const USER = 'some user'; @@ -64,3 +65,4 @@ export const CURSOR = 'c29tZXN0cmluZ2ZvcnlvdQ=='; export const _VERSION = 'WzI5NywxXQ=='; export const VERSION = 1; export const IMMUTABLE = false; +export const IMPORT_TIMEOUT = moment.duration(5, 'minutes'); diff --git a/x-pack/plugins/lists/server/config.mock.ts b/x-pack/plugins/lists/server/config.mock.ts index 3cf5040c73675f..b272f18c4e8090 100644 --- a/x-pack/plugins/lists/server/config.mock.ts +++ b/x-pack/plugins/lists/server/config.mock.ts @@ -6,6 +6,7 @@ import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -21,6 +22,7 @@ export const getConfigMock = (): Partial => ({ export const getConfigMockDecoded = (): ConfigType => ({ enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, diff --git a/x-pack/plugins/lists/server/config.test.ts b/x-pack/plugins/lists/server/config.test.ts index 60501322dcfa2f..40b04edd4c0075 100644 --- a/x-pack/plugins/lists/server/config.test.ts +++ b/x-pack/plugins/lists/server/config.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; + import { ConfigSchema, ConfigType } from './config'; import { getConfigMock, getConfigMockDecoded } from './config.mock'; @@ -61,4 +63,24 @@ describe('config_schema', () => { '[importBufferSize]: Value must be equal to or greater than [1].' ); }); + + test('it throws if the "importTimeout" value is less than 2 minutes', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(2, 'minutes').subtract(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be less than 2 minutes' + ); + }); + + test('it throws if the "importTimeout" value is greater than 1 hour', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(1, 'hour').add(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be greater than 30 minutes' + ); + }); }); diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts index 394f85ecfb6425..6e36d135abfcc3 100644 --- a/x-pack/plugins/lists/server/config.ts +++ b/x-pack/plugins/lists/server/config.ts @@ -9,6 +9,16 @@ import { TypeOf, schema } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), importBufferSize: schema.number({ defaultValue: 1000, min: 1 }), + importTimeout: schema.duration({ + defaultValue: '5m', + validate: (value) => { + if (value.asMinutes() < 2) { + throw new Error('duration cannot be less than 2 minutes'); + } else if (value.asMinutes() > 30) { + throw new Error('duration cannot be greater than 30 minutes'); + } + }, + }), listIndex: schema.string({ defaultValue: '.lists' }), listItemIndex: schema.string({ defaultValue: '.items' }), maxImportPayloadBytes: schema.number({ defaultValue: 9000000, min: 1 }), diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts index 1003a0c52a794f..e162e7829e4562 100644 --- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts @@ -27,6 +27,7 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = parse: false, }, tags: ['access:lists-all'], + timeout: config.importTimeout.asMilliseconds(), }, path: `${LIST_ITEM_URL}/_import`, validate: { diff --git a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts index e5036d561ddc6e..a2959a024f292e 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts @@ -11,6 +11,7 @@ import { getListResponseMock } from '../../../common/schemas/response/list_schem import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -65,6 +66,7 @@ export const getListClientMock = (): ListClient => { config: { enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES,