diff --git a/config/serverless.yml b/config/serverless.yml index 818c1d0552b52..eb0ae3735b6ea 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -37,8 +37,12 @@ server.securityResponseHeaders.strictTransportSecurity: max-age=31536000; includ # Disable embedding for serverless MVP server.securityResponseHeaders.disableEmbedding: true +# default to newest routes +server.versioned.versionResolution: newest +# do not enforce client version check +server.versioned.strictClientVersionCheck: false + # Enforce single "default" space xpack.spaces.maxSpaces: 1 - # Allow unauthenticated access to task manager utilization API for auto-scaling xpack.task_manager.unsafe.authenticate_background_task_utilization: false diff --git a/packages/core/http/core-http-router-server-internal/index.ts b/packages/core/http/core-http-router-server-internal/index.ts index 8c3795306dd9b..026c6b854e8bb 100644 --- a/packages/core/http/core-http-router-server-internal/index.ts +++ b/packages/core/http/core-http-router-server-internal/index.ts @@ -7,7 +7,7 @@ */ export { filterHeaders } from './src/headers'; -export { Router } from './src/router'; +export { Router, type RouterOptions } from './src/router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, CoreKibanaRequest } from './src/request'; export { isSafeMethod } from './src/route'; export { HapiResponseAdapter } from './src/response_adapter'; diff --git a/packages/core/http/core-http-router-server-internal/src/router.test.ts b/packages/core/http/core-http-router-server-internal/src/router.test.ts index 96fb0ca75676d..7de7dd349ec43 100644 --- a/packages/core/http/core-http-router-server-internal/src/router.test.ts +++ b/packages/core/http/core-http-router-server-internal/src/router.test.ts @@ -6,17 +6,22 @@ * Side Public License, v 1. */ -import { Router } from './router'; +import { Router, type RouterOptions } from './router'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { schema } from '@kbn/config-schema'; const logger = loggingSystemMock.create().get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); +const routerOptions: RouterOptions = { + isDev: false, + versionedRouteResolution: 'oldest', +}; + describe('Router', () => { describe('Options', () => { it('throws if validation for a route is not defined explicitly', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); expect( // we use 'any' because validate is a required field () => router.get({ path: '/' } as any, (context, req, res) => res.ok({})) @@ -25,7 +30,7 @@ describe('Router', () => { ); }); it('throws if validation for a route is declared wrong', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); expect(() => router.get( // we use 'any' because validate requires valid Type or function usage @@ -41,7 +46,7 @@ describe('Router', () => { }); it('throws if options.body.output is not a valid value', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); expect(() => router.post( // we use 'any' because TS already checks we cannot provide this body.output @@ -58,14 +63,14 @@ describe('Router', () => { }); it('should default `output: "stream" and parse: false` when no body validation is required but not a GET', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post({ path: '/', validate: {} }, (context, req, res) => res.ok({})); const [route] = router.getRoutes(); expect(route.options).toEqual({ body: { output: 'stream', parse: false } }); }); it('should NOT default `output: "stream" and parse: false` when the user has specified body options (he cares about it)', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', options: { body: { maxBytes: 1 } }, validate: {} }, (context, req, res) => res.ok({}) @@ -75,7 +80,7 @@ describe('Router', () => { }); it('should NOT default `output: "stream" and parse: false` when no body validation is required and GET', () => { - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: {} }, (context, req, res) => res.ok({})); const [route] = router.getRoutes(); expect(route.options).toEqual({}); diff --git a/packages/core/http/core-http-router-server-internal/src/router.ts b/packages/core/http/core-http-router-server-internal/src/router.ts index 6b8fbefddec0c..7aede8bfc01a7 100644 --- a/packages/core/http/core-http-router-server-internal/src/router.ts +++ b/packages/core/http/core-http-router-server-internal/src/router.ts @@ -119,11 +119,14 @@ function validOptions( } /** @internal */ -interface RouterOptions { +export interface RouterOptions { /** Whether we are running in development */ isDev?: boolean; - /** Whether we are running in a serverless */ - isServerless?: boolean; + /** + * Which route resolution algo to use + * @default 'oldest' + */ + versionedRouteResolution?: 'newest' | 'oldest'; } /** @@ -143,7 +146,7 @@ export class Router, - private readonly options: RouterOptions = { isDev: false, isServerless: false } + private readonly options: RouterOptions ) { const buildMethod = (method: Method) => @@ -220,7 +223,7 @@ export class Router { @@ -239,6 +247,7 @@ export class HttpConfig implements IHttpConfig { public externalUrl: IExternalUrlConfig; public xsrf: { disableProtection: boolean; allowlist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; + public versioned: { versionResolution: 'newest' | 'oldest'; strictClientVersionCheck: boolean }; public shutdownTimeout: Duration; public restrictInternalApis: boolean; @@ -286,6 +295,7 @@ export class HttpConfig implements IHttpConfig { this.restrictInternalApis = rawHttpConfig.restrictInternalApis; this.eluMonitor = rawHttpConfig.eluMonitor; + this.versioned = rawHttpConfig.versioned; } } diff --git a/packages/core/http/core-http-server-internal/src/http_server.test.ts b/packages/core/http/core-http-server-internal/src/http_server.test.ts index b6a120e06ab8d..886349e0ea940 100644 --- a/packages/core/http/core-http-server-internal/src/http_server.test.ts +++ b/packages/core/http/core-http-server-internal/src/http_server.test.ts @@ -22,7 +22,7 @@ import type { RouteValidationFunction, RequestHandlerContextBase, } from '@kbn/core-http-server'; -import { Router } from '@kbn/core-http-router-server-internal'; +import { Router, type RouterOptions } from '@kbn/core-http-router-server-internal'; import { HttpConfig } from './http_config'; import { HttpServer } from './http_server'; import { Readable } from 'stream'; @@ -30,6 +30,11 @@ import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import moment from 'moment'; import { of } from 'rxjs'; +const routerOptions: RouterOptions = { + isDev: false, + versionedRouteResolution: 'oldest', +}; + const cookieOptions = { name: 'sid', encryptionKey: 'something_at_least_32_characters', @@ -144,14 +149,14 @@ test('does not allow router registration after server is listening', async () => const { registerRouter } = await server.setup(config); - const router1 = new Router('/foo', logger, enhanceWithContext); + const router1 = new Router('/foo', logger, enhanceWithContext, routerOptions); expect(() => registerRouter(router1)).not.toThrowError(); await server.start(); expect(server.isListening()).toBe(true); - const router2 = new Router('/bar', logger, enhanceWithContext); + const router2 = new Router('/bar', logger, enhanceWithContext, routerOptions); expect(() => registerRouter(router2)).toThrowErrorMatchingInlineSnapshot( `"Routers can be registered only when HTTP server is stopped."` ); @@ -162,19 +167,19 @@ test('allows router registration after server is listening via `registerRouterAf const { registerRouterAfterListening } = await server.setup(config); - const router1 = new Router('/foo', logger, enhanceWithContext); + const router1 = new Router('/foo', logger, enhanceWithContext, routerOptions); expect(() => registerRouterAfterListening(router1)).not.toThrowError(); await server.start(); expect(server.isListening()).toBe(true); - const router2 = new Router('/bar', logger, enhanceWithContext); + const router2 = new Router('/bar', logger, enhanceWithContext, routerOptions); expect(() => registerRouterAfterListening(router2)).not.toThrowError(); }); test('valid params', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.get( { @@ -204,7 +209,7 @@ test('valid params', async () => { }); test('invalid params', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.get( { @@ -238,7 +243,7 @@ test('invalid params', async () => { }); test('valid query', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.get( { @@ -269,7 +274,7 @@ test('valid query', async () => { }); test('invalid query', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.get( { @@ -303,7 +308,7 @@ test('invalid query', async () => { }); test('valid body', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.post( { @@ -338,7 +343,7 @@ test('valid body', async () => { }); test('valid body with validate function', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.post( { @@ -376,7 +381,7 @@ test('valid body with validate function', async () => { }); test('not inline validation - specifying params', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); const bodyValidation = ( { bar, baz }: any = {}, @@ -419,7 +424,7 @@ test('not inline validation - specifying params', async () => { }); test('not inline validation - specifying validation handler', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); const bodyValidation: RouteValidationFunction<{ bar: string; baz: number }> = ( { bar, baz } = {}, @@ -463,7 +468,7 @@ test('not inline validation - specifying validation handler', async () => { // https://github.com/elastic/kibana/issues/47047 test('not inline handler - KibanaRequest', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); const handler = ( context: RequestHandlerContextBase, @@ -512,7 +517,7 @@ test('not inline handler - KibanaRequest', async () => { }); test('not inline handler - RequestHandler', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); const handler: RequestHandler = ( context, @@ -561,7 +566,7 @@ test('not inline handler - RequestHandler', async () => { }); test('invalid body', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.post( { @@ -596,7 +601,7 @@ test('invalid body', async () => { }); test('handles putting', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.put( { @@ -627,7 +632,7 @@ test('handles putting', async () => { }); test('handles deleting', async () => { - const router = new Router('/foo', logger, enhanceWithContext); + const router = new Router('/foo', logger, enhanceWithContext, routerOptions); router.delete( { @@ -667,7 +672,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { rewriteBasePath: false, } as HttpConfig; - const router = new Router('/', logger, enhanceWithContext); + const router = new Router('/', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'value:/' })); router.get({ path: '/foo', validate: false }, (context, req, res) => res.ok({ body: 'value:/foo' }) @@ -722,7 +727,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { rewriteBasePath: true, } as HttpConfig; - const router = new Router('/', logger, enhanceWithContext); + const router = new Router('/', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'value:/' })); router.get({ path: '/foo', validate: false }, (context, req, res) => res.ok({ body: 'value:/foo' }) @@ -772,7 +777,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('with defined `redirectHttpFromPort`', async () => { - const router = new Router('/', logger, enhanceWithContext); + const router = new Router('/', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'value:/' })); const { registerRouter } = await server.setup(configWithSSL); @@ -802,7 +807,7 @@ test('allows attaching metadata to attach meta-data tag strings to a route', asy const tags = ['my:tag']; const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/with-tags', validate: false, options: { tags } }, (context, req, res) => res.ok({ body: { tags: req.route.options.tags } }) ); @@ -821,7 +826,7 @@ test('allows declaring route access to flag a route as public or internal', asyn const access = 'internal'; const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/with-access', validate: false, options: { access } }, (context, req, res) => res.ok({ body: { access: req.route.options.access } }) ); @@ -839,7 +844,7 @@ test('allows declaring route access to flag a route as public or internal', asyn test('infers access flag from path if not defined', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/internal/foo', validate: false }, (context, req, res) => res.ok({ body: { access: req.route.options.access } }) ); @@ -870,7 +875,7 @@ test('infers access flag from path if not defined', async () => { test('exposes route details of incoming request to a route handler', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route })); registerRouter(router); @@ -893,7 +898,7 @@ test('exposes route details of incoming request to a route handler', async () => describe('conditional compression', () => { async function setupServer(innerConfig: HttpConfig) { const { registerRouter, server: innerServer } = await server.setup(innerConfig); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); // we need the large body here so that compression would normally be used const largeRequest = { body: 'hello'.repeat(500), @@ -1001,7 +1006,7 @@ describe('response headers', () => { keepaliveTimeout: 100_000, }); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route })); registerRouter(router); @@ -1018,7 +1023,7 @@ describe('response headers', () => { test('default headers', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: req.route })); registerRouter(router); @@ -1040,7 +1045,7 @@ describe('response headers', () => { test('exposes route details of incoming request to a route handler (POST + payload options)', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1080,7 +1085,7 @@ describe('body options', () => { test('should reject the request because the Content-Type in the request is not valid', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1102,7 +1107,7 @@ describe('body options', () => { test('should reject the request because the payload is too large', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1124,7 +1129,7 @@ describe('body options', () => { test('should not parse the content in the request', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1153,7 +1158,7 @@ describe('timeout options', () => { test('POST routes set the payload timeout', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1187,7 +1192,7 @@ describe('timeout options', () => { test('DELETE routes set the payload timeout', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.delete( { path: '/', @@ -1220,7 +1225,7 @@ describe('timeout options', () => { test('PUT routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.put( { path: '/', @@ -1253,7 +1258,7 @@ describe('timeout options', () => { test('PATCH routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.patch( { path: '/', @@ -1291,7 +1296,7 @@ describe('timeout options', () => { socketTimeout: 11000, }); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get( { path: '/', @@ -1324,7 +1329,7 @@ describe('timeout options', () => { socketTimeout: 11000, }); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get( { path: '/', @@ -1355,7 +1360,7 @@ describe('timeout options', () => { test('idleSocket timeout can be smaller than the payload timeout', async () => { const { registerRouter } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.post( { path: '/', @@ -1382,7 +1387,7 @@ describe('timeout options', () => { test('should return a stream in the body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.put( { path: '/', @@ -1409,7 +1414,7 @@ test('closes sockets on timeout', async () => { ...config, socketTimeout: 1000, }); - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, routerOptions); router.get({ path: '/a', validate: false }, async (context, req, res) => { await new Promise((resolve) => setTimeout(resolve, 2000)); diff --git a/packages/core/http/core-http-server-internal/src/http_service.test.mocks.ts b/packages/core/http/core-http-server-internal/src/http_service.test.mocks.ts index 1b8c74b331d02..8c55043f7caee 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.test.mocks.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.test.mocks.ts @@ -17,6 +17,6 @@ jest.mock('./http_server', () => { }; }); -jest.mock('./lifecycle_handlers', () => ({ +jest.mock('./register_lifecycle_handlers', () => ({ registerCoreHandlers: jest.fn(), })); diff --git a/packages/core/http/core-http-server-internal/src/http_service.test.ts b/packages/core/http/core-http-server-internal/src/http_service.test.ts index 6fe180536e61e..d5c0472322ff6 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.test.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.test.ts @@ -18,6 +18,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; import { contextServiceMock } from '@kbn/core-http-context-server-mocks'; import { Router } from '@kbn/core-http-router-server-internal'; +jest.mock('@kbn/core-http-router-server-internal'); import { HttpService } from './http_service'; import { HttpConfigType, config } from './http_config'; import { cspConfig } from './csp'; @@ -480,3 +481,43 @@ test('does not start http server if configured with `autoListen:false`', async ( expect(httpServer.start).not.toHaveBeenCalled(); await service.stop(); }); + +test('passes versioned config to router', async () => { + const configService = createConfigService({ + versioned: { + versionResolution: 'newest', + strictClientVersionCheck: false, + }, + }); + + const httpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer, registerRouter: jest.fn() }), + start: jest.fn(), + stop: jest.fn(), + }; + const prebootHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer, registerStaticDir: jest.fn() }), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => prebootHttpServer); + mockHttpServer.mockImplementationOnce(() => httpServer); + + const service = new HttpService({ coreId, configService, env, logger }); + await service.preboot(prebootDeps); + const { createRouter } = await service.setup(setupDeps); + await service.stop(); + + createRouter('/foo'); + + expect(Router).toHaveBeenCalledTimes(1); + expect(Router).toHaveBeenNthCalledWith( + 1, + '/foo', + expect.any(Object), // logger + expect.any(Function), // context enhancer + expect.objectContaining({ isDev: true, versionedRouteResolution: 'newest' }) + ); +}); diff --git a/packages/core/http/core-http-server-internal/src/http_service.ts b/packages/core/http/core-http-server-internal/src/http_service.ts index e8c3da57279ce..40d67681f0e7b 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.ts @@ -36,7 +36,7 @@ import { InternalHttpServiceSetup, InternalHttpServiceStart, } from './types'; -import { registerCoreHandlers } from './lifecycle_handlers'; +import { registerCoreHandlers } from './register_lifecycle_handlers'; import { ExternalUrlConfigType, externalUrlConfig, ExternalUrlConfig } from './external_url'; export interface PrebootDeps { @@ -129,7 +129,7 @@ export class HttpService path, this.log, prebootServerRequestHandlerContext.createHandler.bind(null, this.coreContext.coreId), - { isDev: this.env.mode.dev, isServerless: this.env.cliArgs.serverless } + { isDev: this.env.mode.dev, versionedRouteResolution: config.versioned.versionResolution } ); registerCallback(router); @@ -175,7 +175,7 @@ export class HttpService const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); const router = new Router(path, this.log, enhanceHandler, { isDev: this.env.mode.dev, - isServerless: this.env.cliArgs.serverless, + versionedRouteResolution: config.versioned.versionResolution, }); registerRouter(router); return router; diff --git a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts index 03f1dfc4e2006..b58fd1b299b06 100644 --- a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts +++ b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.test.ts @@ -22,6 +22,7 @@ import { createVersionCheckPostAuthHandler, createXsrfPostAuthHandler, } from './lifecycle_handlers'; + import { HttpConfig } from './http_config'; type ToolkitMock = jest.Mocked; @@ -55,6 +56,10 @@ const forgeRequest = ({ }); }; +afterEach(() => { + jest.clearAllMocks(); +}); + describe('xsrf post-auth handler', () => { let toolkit: ToolkitMock; let responseFactory: ReturnType; diff --git a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.ts b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.ts index f22c075149058..90b09ee8349db 100644 --- a/packages/core/http/core-http-server-internal/src/lifecycle_handlers.ts +++ b/packages/core/http/core-http-server-internal/src/lifecycle_handlers.ts @@ -6,12 +6,10 @@ * Side Public License, v 1. */ -import { Env } from '@kbn/config'; import type { OnPostAuthHandler, OnPreResponseHandler } from '@kbn/core-http-server'; import { isSafeMethod } from '@kbn/core-http-router-server-internal'; import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common/src/constants'; import { HttpConfig } from './http_config'; -import { LifecycleRegistrar } from './http_server'; const VERSION_HEADER = 'kbn-version'; const XSRF_HEADER = 'kbn-xsrf'; @@ -100,18 +98,3 @@ export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPre return toolkit.next({ headers: additionalHeaders }); }; }; - -export const registerCoreHandlers = ( - registrar: LifecycleRegistrar, - config: HttpConfig, - env: Env -) => { - // add headers based on config - registrar.registerOnPreResponse(createCustomHeadersPreResponseHandler(config)); - // add extra request checks stuff - registrar.registerOnPostAuth(createXsrfPostAuthHandler(config)); - // add check on version - registrar.registerOnPostAuth(createVersionCheckPostAuthHandler(env.packageInfo.version)); - // add check on header if the route is internal - registrar.registerOnPostAuth(createRestrictInternalRoutesPostAuthHandler(config)); // strictly speaking, we should have access to route.options.access from the request on postAuth -}; diff --git a/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.test.ts b/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.test.ts new file mode 100644 index 0000000000000..ebaefa5ea5dea --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.test.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +jest.mock('./lifecycle_handlers', () => { + const actual = jest.requireActual('./lifecycle_handlers'); + return { + ...actual, + createVersionCheckPostAuthHandler: jest.fn(actual.createVersionCheckPostAuthHandler), + }; +}); + +import { createTestEnv } from '@kbn/config-mocks'; +import type { HttpConfig } from './http_config'; +import { registerCoreHandlers } from './register_lifecycle_handlers'; + +import { createVersionCheckPostAuthHandler } from './lifecycle_handlers'; + +describe('registerCoreHandlers', () => { + it('will not register client version checking if disabled via config', () => { + const registrarMock = { + registerAuth: jest.fn(), + registerOnPostAuth: jest.fn(), + registerOnPreAuth: jest.fn(), + registerOnPreResponse: jest.fn(), + registerOnPreRouting: jest.fn(), + }; + + const config = { + csp: { header: '' }, + xsrf: {}, + versioned: { + versionResolution: 'newest', + strictClientVersionCheck: false, + }, + } as unknown as HttpConfig; + + registerCoreHandlers(registrarMock, config, createTestEnv()); + expect(createVersionCheckPostAuthHandler).toHaveBeenCalledTimes(0); + + config.versioned.strictClientVersionCheck = true; + registerCoreHandlers(registrarMock, config, createTestEnv()); + expect(createVersionCheckPostAuthHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.ts b/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.ts new file mode 100644 index 0000000000000..14561c5d94ef5 --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/register_lifecycle_handlers.ts @@ -0,0 +1,34 @@ +/* + * 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 type { Env } from '@kbn/config'; +import type { HttpConfig } from './http_config'; +import type { LifecycleRegistrar } from './http_server'; +import { + createCustomHeadersPreResponseHandler, + createRestrictInternalRoutesPostAuthHandler, + createVersionCheckPostAuthHandler, + createXsrfPostAuthHandler, +} from './lifecycle_handlers'; + +export const registerCoreHandlers = ( + registrar: LifecycleRegistrar, + config: HttpConfig, + env: Env +) => { + // add headers based on config + registrar.registerOnPreResponse(createCustomHeadersPreResponseHandler(config)); + // add extra request checks stuff + registrar.registerOnPostAuth(createXsrfPostAuthHandler(config)); + if (config.versioned.strictClientVersionCheck !== false) { + // add check on version + registrar.registerOnPostAuth(createVersionCheckPostAuthHandler(env.packageInfo.version)); + } + // add check on header if the route is internal + registrar.registerOnPostAuth(createRestrictInternalRoutesPostAuthHandler(config)); // strictly speaking, we should have access to route.options.access from the request on postAuth +}; diff --git a/packages/core/http/core-http-server-mocks/index.ts b/packages/core/http/core-http-server-mocks/index.ts index a8da0ef898ea9..2813b8686b3c7 100644 --- a/packages/core/http/core-http-server-mocks/index.ts +++ b/packages/core/http/core-http-server-mocks/index.ts @@ -17,4 +17,4 @@ export type { InternalHttpServiceSetupMock, InternalHttpServiceStartMock, } from './src/http_service.mock'; -export { createCoreContext, createHttpServer } from './src/test_utils'; +export { createCoreContext, createHttpServer, createConfigService } from './src/test_utils'; diff --git a/packages/core/http/core-http-server-mocks/src/test_utils.ts b/packages/core/http/core-http-server-mocks/src/test_utils.ts index f0b50a828506c..a23fd54e9840e 100644 --- a/packages/core/http/core-http-server-mocks/src/test_utils.ts +++ b/packages/core/http/core-http-server-mocks/src/test_utils.ts @@ -14,14 +14,27 @@ import { Env } from '@kbn/config'; import { getEnvOptions, configServiceMock } from '@kbn/config-mocks'; import type { CoreContext } from '@kbn/core-base-server-internal'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; -import { HttpService } from '@kbn/core-http-server-internal'; +import { + type HttpConfigType, + type ExternalUrlConfigType, + type CspConfigType, + HttpService, +} from '@kbn/core-http-server-internal'; const coreId = Symbol('core'); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const logger = loggingSystemMock.create(); -const createConfigService = () => { +export const createConfigService = ({ + server, + externalUrl, + csp, +}: Partial<{ + server: Partial; + externalUrl: Partial; + csp: Partial; +}> = {}) => { const configService = configServiceMock.create(); configService.atPath.mockImplementation((path) => { if (path === 'server') { @@ -51,11 +64,17 @@ const createConfigService = () => { keepaliveTimeout: 120_000, socketTimeout: 120_000, restrictInternalApis: false, + versioned: { + versionResolution: 'oldest', + strictClientVersionCheck: true, + }, + ...server, } as any); } if (path === 'externalUrl') { return new BehaviorSubject({ policy: [], + ...externalUrl, } as any); } if (path === 'csp') { @@ -63,6 +82,7 @@ const createConfigService = () => { strict: false, disableEmbedding: false, warnLegacyBrowsers: true, + ...csp, }); } throw new Error(`Unexpected config path: ${path}`); diff --git a/src/core/server/integration_tests/http/cookie_session_storage.test.ts b/src/core/server/integration_tests/http/cookie_session_storage.test.ts index 2142a0aacf9a4..90343da759ccb 100644 --- a/src/core/server/integration_tests/http/cookie_session_storage.test.ts +++ b/src/core/server/integration_tests/http/cookie_session_storage.test.ts @@ -8,26 +8,48 @@ import { parse as parseCookie } from 'tough-cookie'; import supertest from 'supertest'; -import { BehaviorSubject } from 'rxjs'; import { duration as momentDuration } from 'moment'; import { REPO_ROOT } from '@kbn/repo-info'; import { ByteSizeValue } from '@kbn/config-schema'; import { Env } from '@kbn/config'; -import { getEnvOptions, configServiceMock } from '@kbn/config-mocks'; +import { getEnvOptions } from '@kbn/config-mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; import type { CoreContext } from '@kbn/core-base-server-internal'; import { contextServiceMock } from '@kbn/core-http-context-server-mocks'; import { ensureRawRequest } from '@kbn/core-http-router-server-internal'; import { HttpService, createCookieSessionStorageFactory } from '@kbn/core-http-server-internal'; -import { httpServerMock } from '@kbn/core-http-server-mocks'; +import { httpServerMock, createConfigService } from '@kbn/core-http-server-mocks'; let server: HttpService; let logger: ReturnType; let env: Env; let coreContext: CoreContext; -const configService = configServiceMock.create(); + +const configService = createConfigService({ + server: { + hosts: ['http://1.2.3.4'], + maxPayload: new ByteSizeValue(1024), + shutdownTimeout: momentDuration('5s'), + autoListen: true, + healthCheck: { + delay: 2000, + }, + ssl: { + verificationMode: 'none', + } as any, + compression: { enabled: true, brotli: { enabled: false } as any }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any, +}); const contextSetup = contextServiceMock.createSetupContract(); const contextPreboot = contextServiceMock.createPrebootContract(); @@ -40,50 +62,6 @@ const prebootDeps = { context: contextPreboot, }; -configService.atPath.mockImplementation((path) => { - if (path === 'server') { - return new BehaviorSubject({ - hosts: ['http://1.2.3.4'], - maxPayload: new ByteSizeValue(1024), - shutdownTimeout: momentDuration('5s'), - autoListen: true, - healthCheck: { - delay: 2000, - }, - ssl: { - verificationMode: 'none', - }, - compression: { enabled: true, brotli: { enabled: false } }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - securityResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - cors: { - enabled: false, - }, - } as any); - } - if (path === 'externalUrl') { - return new BehaviorSubject({ - policy: [], - } as any); - } - if (path === 'csp') { - return new BehaviorSubject({ - strict: false, - disableEmbedding: false, - warnLegacyBrowsers: true, - }); - } - throw new Error(`Unexpected config path: ${path}`); -}); - interface User { id: string; roles?: string[]; diff --git a/src/core/server/integration_tests/http/http_server.test.ts b/src/core/server/integration_tests/http/http_server.test.ts index 313421fa05eca..f65a5b59487be 100644 --- a/src/core/server/integration_tests/http/http_server.test.ts +++ b/src/core/server/integration_tests/http/http_server.test.ts @@ -54,7 +54,10 @@ describe('Http server', () => { const { registerRouter, server: innerServer } = await server.setup(config); innerServerListener = innerServer.listener; - const router = new Router('', logger, enhanceWithContext); + const router = new Router('', logger, enhanceWithContext, { + isDev: false, + versionedRouteResolution: 'oldest', + }); router.post( { path: '/', diff --git a/src/core/server/integration_tests/http/lifecycle_handlers.test.ts b/src/core/server/integration_tests/http/lifecycle_handlers.test.ts index 2ba0aa0e6f8e4..b7d1ec5e9e670 100644 --- a/src/core/server/integration_tests/http/lifecycle_handlers.test.ts +++ b/src/core/server/integration_tests/http/lifecycle_handlers.test.ts @@ -7,14 +7,10 @@ */ import supertest from 'supertest'; -import moment from 'moment'; import { kibanaPackageJson } from '@kbn/repo-info'; -import { BehaviorSubject } from 'rxjs'; -import { ByteSizeValue } from '@kbn/config-schema'; -import { configServiceMock } from '@kbn/config-mocks'; import type { IRouter, RouteRegistrar } from '@kbn/core-http-server'; import { contextServiceMock } from '@kbn/core-http-context-server-mocks'; -import { createHttpServer } from '@kbn/core-http-server-mocks'; +import { createConfigService, createHttpServer } from '@kbn/core-http-server-mocks'; import { HttpService, HttpServerSetup } from '@kbn/core-http-server-internal'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; @@ -31,97 +27,29 @@ const setupDeps = { executionContext: executionContextServiceMock.createInternalSetupContract(), }; -interface HttpConfigTestOptions { - enabled?: boolean; -} -const setUpDefaultServerConfig = ({ enabled }: HttpConfigTestOptions = {}) => - ({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - shutdownTimeout: moment.duration(30, 'seconds'), - autoListen: true, - ssl: { - enabled: false, - }, - cors: { - enabled: false, - }, - compression: { enabled: true, brotli: { enabled: false } }, - name: kibanaName, - securityResponseHeaders: { - // reflects default config - strictTransportSecurity: null, - xContentTypeOptions: 'nosniff', - referrerPolicy: 'strict-origin-when-cross-origin', - permissionsPolicy: null, - crossOriginOpenerPolicy: 'same-origin', - }, - customResponseHeaders: { - 'some-header': 'some-value', - 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders - }, - xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - restrictInternalApis: enabled ?? false, // reflects default for public routes - } as any); - describe('core lifecycle handlers', () => { let server: HttpService; let innerServer: HttpServerSetup['server']; let router: IRouter; beforeEach(async () => { - const configService = configServiceMock.create(); - configService.atPath.mockImplementation((path) => { - if (path === 'server') { - return new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - shutdownTimeout: moment.duration(30, 'seconds'), - autoListen: true, - ssl: { - enabled: false, - }, - cors: { - enabled: false, - }, - compression: { enabled: true, brotli: { enabled: false } }, - name: kibanaName, - securityResponseHeaders: { - // reflects default config - strictTransportSecurity: null, - xContentTypeOptions: 'nosniff', - referrerPolicy: 'strict-origin-when-cross-origin', - permissionsPolicy: null, - crossOriginOpenerPolicy: 'same-origin', - }, - customResponseHeaders: { - 'some-header': 'some-value', - 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders - }, - xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any); - } - if (path === 'externalUrl') { - return new BehaviorSubject({ - policy: [], - } as any); - } - if (path === 'csp') { - return new BehaviorSubject({ - strict: false, - disableEmbedding: false, - warnLegacyBrowsers: true, - }); - } - throw new Error(`Unexpected config path: ${path}`); + const configService = createConfigService({ + server: { + name: kibanaName, + securityResponseHeaders: { + // reflects default config + strictTransportSecurity: null, + xContentTypeOptions: 'nosniff', + referrerPolicy: 'strict-origin-when-cross-origin', + permissionsPolicy: null, + crossOriginOpenerPolicy: 'same-origin', + } as any, + customResponseHeaders: { + 'some-header': 'some-value', + 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders + }, + xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, + }, }); server = createHttpServer({ configService }); @@ -321,32 +249,13 @@ describe('core lifecycle handlers', () => { }); }); -describe('core lifecyle handers with restrict internal routes enforced', () => { +describe('core lifecycle handlers with restrict internal routes enforced', () => { let server: HttpService; let innerServer: HttpServerSetup['server']; let router: IRouter; beforeEach(async () => { - const configService = configServiceMock.create(); - configService.atPath.mockImplementation((path) => { - if (path === 'server') { - return new BehaviorSubject(setUpDefaultServerConfig({ enabled: true })); - } - if (path === 'externalUrl') { - return new BehaviorSubject({ - policy: [], - } as any); - } - if (path === 'csp') { - return new BehaviorSubject({ - strict: false, - disableEmbedding: false, - warnLegacyBrowsers: true, - }); - } - - throw new Error(`Unexpected config path: ${path}`); - }); + const configService = createConfigService({ server: { restrictInternalApis: true } }); server = createHttpServer({ configService }); await server.preboot({ context: contextServiceMock.createPrebootContract() }); @@ -391,3 +300,45 @@ describe('core lifecyle handers with restrict internal routes enforced', () => { }); }); }); + +describe('core lifecycle handlers with no strict client version check', () => { + const testRoute = '/version_check/test/route'; + let server: HttpService; + let innerServer: HttpServerSetup['server']; + let router: IRouter; + + beforeEach(async () => { + const configService = createConfigService({ + server: { + versioned: { + strictClientVersionCheck: false, + versionResolution: 'newest', + }, + }, + }); + server = createHttpServer({ configService }); + await server.preboot({ context: contextServiceMock.createPrebootContract() }); + const serverSetup = await server.setup(setupDeps); + router = serverSetup.createRouter('/'); + router.get({ path: testRoute, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + innerServer = serverSetup.server; + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('accepts requests that do not include a version header', async () => { + await supertest(innerServer.listener).get(testRoute).expect(200, 'ok'); + }); + + it('accepts requests with any version passed in the version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .set(versionHeader, 'what-have-you') + .expect(200, 'ok'); + }); +}); diff --git a/src/core/server/integration_tests/http/router.test.ts b/src/core/server/integration_tests/http/router.test.ts index 6e0740973d0c2..c9dca25eea044 100644 --- a/src/core/server/integration_tests/http/router.test.ts +++ b/src/core/server/integration_tests/http/router.test.ts @@ -1891,7 +1891,10 @@ describe('registerRouterAfterListening', () => { const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); - const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext, { + isDev: false, + versionedRouteResolution: 'oldest', + }); otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { return res.ok({ body: 'hello from other router' }); }); @@ -1923,7 +1926,10 @@ describe('registerRouterAfterListening', () => { const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); - const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext, { + isDev: false, + versionedRouteResolution: 'oldest', + }); otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { return res.ok({ body: 'hello from other router' }); }); diff --git a/src/core/server/integration_tests/http/versioned_router.test.ts b/src/core/server/integration_tests/http/versioned_router.test.ts index 4ab870e15a6f9..89193c6568a6b 100644 --- a/src/core/server/integration_tests/http/versioned_router.test.ts +++ b/src/core/server/integration_tests/http/versioned_router.test.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; import { contextServiceMock } from '@kbn/core-http-context-server-mocks'; -import { createHttpServer } from '@kbn/core-http-server-mocks'; +import { createHttpServer, createConfigService } from '@kbn/core-http-server-mocks'; import type { HttpService } from '@kbn/core-http-server-internal'; import type { IRouter } from '@kbn/core-http-server'; import type { CliArgs } from '@kbn/config'; @@ -30,6 +30,18 @@ describe('Routing versioned requests', () => { server = createHttpServer({ logger, env: createTestEnv({ envOptions: getEnvOptions({ cliArgs }) }), + configService: createConfigService({ + // We manually sync the config in our mock at this point + server: + cliArgs.serverless === true + ? { + versioned: { + versionResolution: 'newest', + strictClientVersionCheck: false, + }, + } + : undefined, + }), }); await server.preboot({ context: contextServiceMock.createPrebootContract() }); const { server: innerServer, createRouter } = await server.setup(setupDeps);