[http] Do not do client version check on serverless as we do for onprem (#159101)

## Summary

This PR introduces two changes:

(1) Refactors the handler resolution logic to _not_ depend on the
`--serverless` cli arg by adding a new piece of config
`server.versioned.routeResolution` that accepts `newest | oldest`. This
piece of config is passed down instead of the `serverless` cli arg as
well as updating test cases

(2) Adds a new piece of config to turn off the client version checking.
This will be needed for rolling upgrades to allow old browser traffic to
reach new Kibana servers when there is stack version change.

Close https://github.com/elastic/kibana/issues/158723

## Open questions

* Do we want to make the version check still take _major_ version bumps
into account?
This commit is contained in:
Jean-Louis Leysens 2023-06-13 14:12:55 +02:00 committed by GitHub
parent d082d78e60
commit 7d07149323
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 361 additions and 244 deletions

View file

@ -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

View file

@ -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';

View file

@ -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({});

View file

@ -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<Context extends RequestHandlerContextBase = RequestHandlerCo
public readonly routerPath: string,
private readonly log: Logger,
private readonly enhanceWithContext: ContextEnhancer<any, any, any, any, any>,
private readonly options: RouterOptions = { isDev: false, isServerless: false }
private readonly options: RouterOptions
) {
const buildMethod =
<Method extends RouteMethod>(method: Method) =>
@ -220,7 +223,7 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
this.versionedRouter = CoreVersionedRouter.from({
router: this,
isDev: this.options.isDev,
defaultHandlerResolutionStrategy: this.options.isServerless ? 'newest' : 'oldest',
defaultHandlerResolutionStrategy: this.options.versionedRouteResolution,
});
}
return this.versionedRouter;

View file

@ -18,8 +18,12 @@ export type {
} from './src/types';
export { BasePath } from './src/base_path_service';
export { cspConfig, CspConfig } from './src/csp';
export { cspConfig, CspConfig, type CspConfigType } from './src/csp';
export { externalUrlConfig, ExternalUrlConfig } from './src/external_url';
export {
externalUrlConfig,
ExternalUrlConfig,
type ExternalUrlConfigType,
} from './src/external_url';
export { createCookieSessionStorageFactory } from './src/cookie_session_storage';

View file

@ -126,6 +126,10 @@ Object {
],
"truststore": Object {},
},
"versioned": Object {
"strictClientVersionCheck": true,
"versionResolution": "oldest",
},
"xsrf": Object {
"allowlist": Array [],
"disableProtection": false,

View file

@ -167,6 +167,14 @@ const configSchema = schema.object(
}
),
restrictInternalApis: schema.boolean({ defaultValue: false }), // allow access to internal routes by default to prevent breaking changes in current offerings
versioned: schema.object({
// Which handler resolution algo to use: "newest" or "oldest"
versionResolution: schema.oneOf([schema.literal('newest'), schema.literal('oldest')], {
defaultValue: 'oldest',
}),
// Whether we enforce version checks on client requests
strictClientVersionCheck: schema.boolean({ defaultValue: true }),
}),
},
{
validate: (rawConfig) => {
@ -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;
}
}

View file

@ -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<unknown, unknown, { bar: string; baz: number }> = (
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));

View file

@ -17,6 +17,6 @@ jest.mock('./http_server', () => {
};
});
jest.mock('./lifecycle_handlers', () => ({
jest.mock('./register_lifecycle_handlers', () => ({
registerCoreHandlers: jest.fn(),
}));

View file

@ -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' })
);
});

View file

@ -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<Context>(path, this.log, enhanceHandler, {
isDev: this.env.mode.dev,
isServerless: this.env.cliArgs.serverless,
versionedRouteResolution: config.versioned.versionResolution,
});
registerRouter(router);
return router;

View file

@ -22,6 +22,7 @@ import {
createVersionCheckPostAuthHandler,
createXsrfPostAuthHandler,
} from './lifecycle_handlers';
import { HttpConfig } from './http_config';
type ToolkitMock = jest.Mocked<OnPreResponseToolkit & OnPostAuthToolkit & OnPreRoutingToolkit>;
@ -55,6 +56,10 @@ const forgeRequest = ({
});
};
afterEach(() => {
jest.clearAllMocks();
});
describe('xsrf post-auth handler', () => {
let toolkit: ToolkitMock;
let responseFactory: ReturnType<typeof mockRouter.createResponseFactory>;

View file

@ -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
};

View file

@ -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);
});
});

View file

@ -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
};

View file

@ -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';

View file

@ -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<HttpConfigType>;
externalUrl: Partial<ExternalUrlConfigType>;
csp: Partial<CspConfigType>;
}> = {}) => {
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}`);

View file

@ -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<typeof loggingSystemMock.create>;
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[];

View file

@ -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: '/',

View file

@ -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');
});
});

View file

@ -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' });
});

View file

@ -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);