diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fa2a580b4ae83..3388c8f87b798 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -922,6 +922,7 @@ x-pack/test/observability_functional @elastic/actionable-observability /WORKSPACE.bazel @elastic/kibana-operations /.buildkite/ @elastic/kibana-operations /kbn_pm/ @elastic/kibana-operations +/x-pack/dev-tools @elastic/kibana-operations # Appex QA /src/dev/code_coverage @elastic/appex-qa diff --git a/config/serverless.yml b/config/serverless.yml index ab99048418269..6a9c4793dc85d 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -18,6 +18,8 @@ xpack.license_management.enabled: false #xpack.canvas.enabled: false #only disabable in dev-mode xpack.reporting.enabled: false +# Enforce restring access to internal APIs see https://github.com/elastic/kibana/issues/151940 +# server.restrictInternalApis: true # Telemetry enabled by default and not disableable via UI telemetry.optIn: true telemetry.allowChangingOptInStatus: false diff --git a/packages/core/http/core-http-browser-internal/src/fetch.test.ts b/packages/core/http/core-http-browser-internal/src/fetch.test.ts index 28a89fb099183..92539cfe14c66 100644 --- a/packages/core/http/core-http-browser-internal/src/fetch.test.ts +++ b/packages/core/http/core-http-browser-internal/src/fetch.test.ts @@ -160,6 +160,7 @@ describe('Fetch', () => { expect(fetchMock.lastOptions()!.headers).toMatchObject({ 'content-type': 'application/json', 'kbn-version': 'VERSION', + 'x-elastic-internal-origin': 'Kibana', myheader: 'foo', }); }); @@ -168,13 +169,30 @@ describe('Fetch', () => { fetchMock.get('*', {}); await expect( fetchInstance.fetch('/my/path', { - headers: { myHeader: 'foo', 'kbn-version': 'CUSTOM!' }, + headers: { + myHeader: 'foo', + 'kbn-version': 'CUSTOM!', + }, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Invalid fetch headers, headers beginning with \\"kbn-\\" are not allowed: [kbn-version]"` ); }); + it('should not allow overwriting of x-elastic-internal-origin header', async () => { + fetchMock.get('*', {}); + await expect( + fetchInstance.fetch('/my/path', { + headers: { + myHeader: 'foo', + 'x-elastic-internal-origin': 'anything', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid fetch headers, headers beginning with \\"x-elastic-internal-\\" are not allowed: [x-elastic-internal-origin]"` + ); + }); + it('should not set kbn-system-request header by default', async () => { fetchMock.get('*', {}); await fetchInstance.fetch('/my/path', { @@ -310,6 +328,7 @@ describe('Fetch', () => { headers: { 'content-type': 'application/json', 'kbn-version': 'VERSION', + 'x-elastic-internal-origin': 'Kibana', }, }); }); diff --git a/packages/core/http/core-http-browser-internal/src/fetch.ts b/packages/core/http/core-http-browser-internal/src/fetch.ts index 494b7c3d2eb58..0997329c84798 100644 --- a/packages/core/http/core-http-browser-internal/src/fetch.ts +++ b/packages/core/http/core-http-browser-internal/src/fetch.ts @@ -19,7 +19,10 @@ import type { HttpResponse, HttpFetchOptionsWithPath, } from '@kbn/core-http-browser'; -import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; import { HttpFetchError } from './http_fetch_error'; import { HttpInterceptController } from './http_intercept_controller'; import { interceptRequest, interceptResponse } from './intercept'; @@ -131,6 +134,7 @@ export class Fetch { ...options.headers, 'kbn-version': this.params.kibanaVersion, [ELASTIC_HTTP_VERSION_HEADER]: version, + [X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana', ...(!isEmpty(context) ? new ExecutionContextContainer(context).toHeader() : {}), }), }; @@ -223,12 +227,23 @@ const validateFetchArguments = ( ); } - const invalidHeaders = Object.keys(fullOptions.headers ?? {}).filter((headerName) => + const invalidKbnHeaders = Object.keys(fullOptions.headers ?? {}).filter((headerName) => headerName.startsWith('kbn-') ); - if (invalidHeaders.length) { + const invalidInternalOriginProducHeader = Object.keys(fullOptions.headers ?? {}).filter( + (headerName) => headerName.includes(X_ELASTIC_INTERNAL_ORIGIN_REQUEST) + ); + + if (invalidKbnHeaders.length) { + throw new Error( + `Invalid fetch headers, headers beginning with "kbn-" are not allowed: [${invalidKbnHeaders.join( + ',' + )}]` + ); + } + if (invalidInternalOriginProducHeader.length) { throw new Error( - `Invalid fetch headers, headers beginning with "kbn-" are not allowed: [${invalidHeaders.join( + `Invalid fetch headers, headers beginning with "x-elastic-internal-" are not allowed: [${invalidInternalOriginProducHeader.join( ',' )}]` ); diff --git a/packages/core/http/core-http-browser/src/types.ts b/packages/core/http/core-http-browser/src/types.ts index f49027cf5a0c0..46c9e590081ff 100644 --- a/packages/core/http/core-http-browser/src/types.ts +++ b/packages/core/http/core-http-browser/src/types.ts @@ -148,6 +148,7 @@ export interface IAnonymousPaths { /** * Headers to append to the request. Any headers that begin with `kbn-` are considered private to Core and will cause * {@link HttpHandler} to throw an error. + * Includes the required Header that validates internal requests to internal APIs * @public */ export interface HttpHeadersInit { diff --git a/packages/core/http/core-http-common/index.ts b/packages/core/http/core-http-common/index.ts index 1a2c19b4ea6b2..f430db35b6a1d 100644 --- a/packages/core/http/core-http-common/index.ts +++ b/packages/core/http/core-http-common/index.ts @@ -9,4 +9,4 @@ export type { IExternalUrlPolicy } from './src/external_url_policy'; export type { ApiVersion } from './src/versioning'; -export { ELASTIC_HTTP_VERSION_HEADER } from './src/constants'; +export { ELASTIC_HTTP_VERSION_HEADER, X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from './src/constants'; diff --git a/packages/core/http/core-http-common/src/constants.ts b/packages/core/http/core-http-common/src/constants.ts index b231fdd745b36..3227b38417523 100644 --- a/packages/core/http/core-http-common/src/constants.ts +++ b/packages/core/http/core-http-common/src/constants.ts @@ -8,3 +8,5 @@ /** @internal */ export const ELASTIC_HTTP_VERSION_HEADER = 'elastic-api-version' as const; + +export const X_ELASTIC_INTERNAL_ORIGIN_REQUEST = 'x-elastic-internal-origin' as const; diff --git a/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap b/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap index 4742ac4d1fe48..87a5efcc5d279 100644 --- a/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap +++ b/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap @@ -67,6 +67,7 @@ Object { "allowFromAnyIp": false, "ipAllowlist": Array [], }, + "restrictInternalApis": false, "rewriteBasePath": false, "securityResponseHeaders": Object { "crossOriginOpenerPolicy": "same-origin", diff --git a/packages/core/http/core-http-server-internal/src/http_config.ts b/packages/core/http/core-http-server-internal/src/http_config.ts index 1fae2568edffd..53417d9c533f8 100644 --- a/packages/core/http/core-http-server-internal/src/http_config.ts +++ b/packages/core/http/core-http-server-internal/src/http_config.ts @@ -150,6 +150,7 @@ const configSchema = schema.object( }, } ), + restrictInternalApis: schema.boolean({ defaultValue: false }), // allow access to internal routes by default to prevent breaking changes in current offerings }, { validate: (rawConfig) => { @@ -223,6 +224,7 @@ export class HttpConfig implements IHttpConfig { public xsrf: { disableProtection: boolean; allowlist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; public shutdownTimeout: Duration; + public restrictInternalApis: boolean; /** * @internal @@ -263,6 +265,7 @@ export class HttpConfig implements IHttpConfig { this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; this.shutdownTimeout = rawHttpConfig.shutdownTimeout; + this.restrictInternalApis = rawHttpConfig.restrictInternalApis; } } 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 d13bd001bbbb9..03f1dfc4e2006 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 @@ -13,10 +13,12 @@ import type { OnPreResponseToolkit, OnPostAuthToolkit, OnPreRoutingToolkit, + OnPostAuthHandler, } from '@kbn/core-http-server'; import { mockRouter } from '@kbn/core-http-router-server-mocks'; import { createCustomHeadersPreResponseHandler, + createRestrictInternalRoutesPostAuthHandler, createVersionCheckPostAuthHandler, createXsrfPostAuthHandler, } from './lifecycle_handlers'; @@ -242,6 +244,108 @@ describe('versionCheck post-auth handler', () => { }); }); +describe('restrictInternal post-auth handler', () => { + let toolkit: ToolkitMock; + let responseFactory: ReturnType; + + beforeEach(() => { + toolkit = createToolkit(); + responseFactory = mockRouter.createResponseFactory(); + }); + const createForgeRequest = ( + access: 'internal' | 'public', + headers: Record | undefined = {} + ) => { + return forgeRequest({ + method: 'get', + headers, + path: `/${access}/some-path`, + kibanaRouteOptions: { + xsrfRequired: false, + access, + }, + }); + }; + + const createForwardSuccess = (handler: OnPostAuthHandler, request: KibanaRequest) => { + toolkit.next.mockReturnValue('next' as any); + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(result).toBe('next'); + }; + + describe('when restriction is enabled', () => { + const config = createConfig({ + name: 'my-server-name', + restrictInternalApis: true, + }); + it('returns a bad request if called without internal origin header for internal API', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('internal'); + + responseFactory.badRequest.mockReturnValue('badRequest' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).not.toHaveBeenCalled(); + expect(responseFactory.badRequest.mock.calls[0][0]?.body).toMatch( + /uri \[.*\/internal\/some-path\] with method \[get\] exists but is not available with the current configuration/ + ); + expect(result).toBe('badRequest'); + }); + + it('forward the request to the next interceptor if called with internal origin header for internal API', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('internal', { 'x-elastic-internal-origin': 'Kibana' }); + createForwardSuccess(handler, request); + }); + + it('forward the request to the next interceptor if called with internal origin header for public APIs', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('public', { 'x-elastic-internal-origin': 'Kibana' }); + createForwardSuccess(handler, request); + }); + + it('forward the request to the next interceptor if called without internal origin header for public APIs', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('public'); + createForwardSuccess(handler, request); + }); + }); + + describe('when restriction is not enabled', () => { + const config = createConfig({ + name: 'my-server-name', + restrictInternalApis: false, + }); + it('forward the request to the next interceptor if called without internal origin header for internal APIs', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('internal'); + createForwardSuccess(handler, request); + }); + + it('forward the request to the next interceptor if called with internal origin header for internal API', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('internal', { 'x-elastic-internal-origin': 'Kibana' }); + createForwardSuccess(handler, request); + }); + + it('forward the request to the next interceptor if called without internal origin header for public APIs', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('public'); + createForwardSuccess(handler, request); + }); + + it('forward the request to the next interceptor if called with internal origin header for public APIs', () => { + const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig); + const request = createForgeRequest('public', { 'x-elastic-internal-origin': 'Kibana' }); + createForwardSuccess(handler, request); + }); + }); +}); + describe('customHeaders pre-response handler', () => { let toolkit: ToolkitMock; 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 af148413265e8..f22c075149058 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 @@ -9,6 +9,7 @@ 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'; @@ -39,6 +40,27 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler }; }; +export const createRestrictInternalRoutesPostAuthHandler = ( + config: HttpConfig +): OnPostAuthHandler => { + const isRestrictionEnabled = config.restrictInternalApis; + + return (request, response, toolkit) => { + const isInternalRoute = request.route.options.access === 'internal'; + + // only check if the header is present, not it's content. + const hasInternalKibanaRequestHeader = X_ELASTIC_INTERNAL_ORIGIN_REQUEST in request.headers; + + if (isRestrictionEnabled && isInternalRoute && !hasInternalKibanaRequestHeader) { + // throw 400 + return response.badRequest({ + body: `uri [${request.url}] with method [${request.route.method}] exists but is not available with the current configuration`, + }); + } + return toolkit.next(); + }; +}; + export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPostAuthHandler => { return (request, response, toolkit) => { const requestVersion = request.headers[VERSION_HEADER]; @@ -60,7 +82,6 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost }; }; -// TODO: implement header required for accessing internal routes. See https://github.com/elastic/kibana/issues/151940 export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => { const { name: serverName, @@ -76,7 +97,6 @@ export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPre 'Content-Security-Policy': cspHeader, [KIBANA_NAME_HEADER]: serverName, }; - return toolkit.next({ headers: additionalHeaders }); }; }; @@ -86,7 +106,12 @@ export const registerCoreHandlers = ( 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-mocks/src/test_utils.ts b/packages/core/http/core-http-server-mocks/src/test_utils.ts index 17b493e96b869..f0b50a828506c 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 @@ -50,6 +50,7 @@ const createConfigService = () => { shutdownTimeout: moment.duration(30, 'seconds'), keepaliveTimeout: 120_000, socketTimeout: 120_000, + restrictInternalApis: false, } as any); } if (path === 'externalUrl') { diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.test.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.test.ts index 43c633290efcf..c002e12363af6 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.test.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.test.ts @@ -347,7 +347,11 @@ describe('throwIfAnyTypeNotVisibleByAPI', () => { describe('logWarnOnExternalRequest', () => { let logger: MockedLogger; - const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' }; + const firstPartyRequestHeaders = { + 'kbn-version': 'a', + referer: 'b', + 'x-elastic-internal-origin': 'foo', + }; const kibRequest = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders }); const extRequest = httpServerMock.createKibanaRequest(); diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.ts index 03023d167a38f..7daea1a33237e 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/utils.ts @@ -157,7 +157,9 @@ export interface BulkGetItem { export function isKibanaRequest({ headers }: KibanaRequest) { // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. // We can't be 100% certain, but this is a reasonable attempt. - return headers && headers['kbn-version'] && headers.referer; + return ( + headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin'] + ); } export interface LogWarnOnExternalRequest { diff --git a/packages/core/ui-settings/core-ui-settings-browser-internal/src/__snapshots__/ui_settings_api.test.ts.snap b/packages/core/ui-settings/core-ui-settings-browser-internal/src/__snapshots__/ui_settings_api.test.ts.snap index 6599746d33bf1..abee12da71e6b 100644 --- a/packages/core/ui-settings/core-ui-settings-browser-internal/src/__snapshots__/ui_settings_api.test.ts.snap +++ b/packages/core/ui-settings/core-ui-settings-browser-internal/src/__snapshots__/ui_settings_api.test.ts.snap @@ -9,6 +9,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -20,6 +21,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -36,6 +38,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -47,6 +50,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -63,6 +67,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -74,6 +79,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -113,6 +119,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -129,6 +136,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -140,6 +148,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -156,6 +165,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -167,6 +177,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -183,6 +194,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -194,6 +206,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, @@ -233,6 +246,7 @@ Array [ "accept": "application/json", "content-type": "application/json", "kbn-version": "kibanaVersion", + "x-elastic-internal-origin": "Kibana", }, "method": "POST", }, diff --git a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts index a9b532148e5fe..86d1e1bbd8712 100644 --- a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts +++ b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts @@ -51,7 +51,11 @@ describe('CoreUsageStatsClient', () => { ); return { usageStatsClient, debugLoggerMock, basePathMock, repositoryMock }; }; - const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' }; // as long as these two header fields are truthy, this will be treated like a first-party request + const firstPartyRequestHeaders = { + 'kbn-version': 'a', + referer: 'b', + 'x-elastic-internal-origin': 'c', + }; // as long as these header fields are truthy, this will be treated like a first-party request const incrementOptions = { refresh: false }; describe('#getUsageStats', () => { diff --git a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts index fdb653c5c5a2a..f66e51ed2bf77 100644 --- a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts +++ b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts @@ -245,7 +245,9 @@ function getFieldsForCounter(prefix: string) { } function getIsKibanaRequest({ headers }: KibanaRequest) { - // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // The presence of these request headers gives us a good indication that this is a first-party request from the Kibana client. // We can't be 100% certain, but this is a reasonable attempt. - return headers && headers['kbn-version'] && headers.referer; + return ( + headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin'] + ); } diff --git a/packages/kbn-cli-dev-mode/src/config/http_config.ts b/packages/kbn-cli-dev-mode/src/config/http_config.ts index f39bf673f597e..7f7a83defb8c3 100644 --- a/packages/kbn-cli-dev-mode/src/config/http_config.ts +++ b/packages/kbn-cli-dev-mode/src/config/http_config.ts @@ -38,6 +38,7 @@ export const httpConfigSchema = schema.object( }), }), ssl: sslSchema, + restrictInternalApis: schema.boolean({ defaultValue: false }), }, { unknowns: 'ignore' } ); @@ -54,6 +55,7 @@ export class HttpConfig implements IHttpConfig { socketTimeout: number; cors: ICorsConfig; ssl: ISslConfig; + restrictInternalApis: boolean; constructor(rawConfig: HttpConfigType) { this.basePath = rawConfig.basePath; @@ -65,5 +67,6 @@ export class HttpConfig implements IHttpConfig { this.socketTimeout = rawConfig.socketTimeout; this.cors = rawConfig.cors; this.ssl = new SslConfig(rawConfig.ssl); + this.restrictInternalApis = rawConfig.restrictInternalApis; } } diff --git a/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts b/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts index 1c6842f3c00a6..bb85786d97cc7 100644 --- a/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts +++ b/packages/kbn-cli-dev-mode/src/integration_tests/base_path_proxy_server.test.ts @@ -46,6 +46,7 @@ describe('BasePathProxyServer', () => { }, ssl: { enabled: false }, maxPayload: new ByteSizeValue(1024), + restrictInternalApis: false, }; const serverOptions = getServerOptions(config); diff --git a/packages/kbn-health-gateway-server/src/server/server_config.test.ts b/packages/kbn-health-gateway-server/src/server/server_config.test.ts index 66f82c0f1502b..8f4d2fc6c8071 100644 --- a/packages/kbn-health-gateway-server/src/server/server_config.test.ts +++ b/packages/kbn-health-gateway-server/src/server/server_config.test.ts @@ -20,6 +20,7 @@ describe('server config', () => { "valueInBytes": 1048576, }, "port": 3000, + "restrictInternalApis": false, "shutdownTimeout": "PT30S", "socketTimeout": 120000, "ssl": Object { @@ -188,4 +189,34 @@ describe('server config', () => { `); }); }); + + describe('restrictInternalApis', () => { + test('is false by default', () => { + const configSchema = config.schema; + const obj = {}; + expect(new ServerConfig(configSchema.validate(obj)).restrictInternalApis).toBe(false); + }); + + test('can specify retriction on access to internal APIs', () => { + const configSchema = config.schema; + expect( + new ServerConfig(configSchema.validate({ restrictInternalApis: true })).restrictInternalApis + ).toBe(true); + + expect( + new ServerConfig(configSchema.validate({ restrictInternalApis: false })) + .restrictInternalApis + ).toBe(false); + }); + + test('throws if not boolean', () => { + const configSchema = config.schema; + expect(() => configSchema.validate({ restrictInternalApis: 100 })).toThrowError( + 'restrictInternalApis]: expected value of type [boolean] but got [number]' + ); + expect(() => configSchema.validate({ restrictInternalApis: 'something' })).toThrowError( + 'restrictInternalApis]: expected value of type [boolean] but got [string]' + ); + }); + }); }); diff --git a/packages/kbn-health-gateway-server/src/server/server_config.ts b/packages/kbn-health-gateway-server/src/server/server_config.ts index 79c4f760c4408..3f89f422d6f6e 100644 --- a/packages/kbn-health-gateway-server/src/server/server_config.ts +++ b/packages/kbn-health-gateway-server/src/server/server_config.ts @@ -40,6 +40,7 @@ const configSchema = schema.object( defaultValue: 120000, }), ssl: sslSchema, + restrictInternalApis: schema.boolean({ defaultValue: false }), }, { validate: (rawConfig) => { @@ -74,6 +75,7 @@ export class ServerConfig implements IHttpConfig { socketTimeout: number; ssl: ISslConfig; cors: ICorsConfig; + restrictInternalApis: boolean; constructor(rawConfig: ServerConfigType) { this.host = rawConfig.host; @@ -88,5 +90,6 @@ export class ServerConfig implements IHttpConfig { allowCredentials: false, allowOrigin: ['*'], }; + this.restrictInternalApis = rawConfig.restrictInternalApis; } } diff --git a/packages/kbn-journeys/services/auth.ts b/packages/kbn-journeys/services/auth.ts index e0f89d184e462..b14a99d317578 100644 --- a/packages/kbn-journeys/services/auth.ts +++ b/packages/kbn-journeys/services/auth.ts @@ -57,6 +57,7 @@ export class Auth { 'kbn-version': version, 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', + 'x-elastic-internal-origin': 'Kibana', }, validateStatus: () => true, maxRedirects: 0, diff --git a/packages/kbn-server-http-tools/src/get_server_options.test.ts b/packages/kbn-server-http-tools/src/get_server_options.test.ts index 4af9b34dfc5f9..3fe164d0b8ada 100644 --- a/packages/kbn-server-http-tools/src/get_server_options.test.ts +++ b/packages/kbn-server-http-tools/src/get_server_options.test.ts @@ -38,6 +38,7 @@ const createConfig = (parts: Partial): IHttpConfig => ({ enabled: false, ...parts.ssl, }, + restrictInternalApis: false, }); describe('getServerOptions', () => { diff --git a/packages/kbn-server-http-tools/src/types.ts b/packages/kbn-server-http-tools/src/types.ts index 9aec520fb3a31..57832de28e724 100644 --- a/packages/kbn-server-http-tools/src/types.ts +++ b/packages/kbn-server-http-tools/src/types.ts @@ -18,6 +18,7 @@ export interface IHttpConfig { cors: ICorsConfig; ssl: ISslConfig; shutdownTimeout: Duration; + restrictInternalApis: boolean; } export interface ICorsConfig { 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 7033abc4174fa..2ba0aa0e6f8e4 100644 --- a/src/core/server/integration_tests/http/lifecycle_handlers.test.ts +++ b/src/core/server/integration_tests/http/lifecycle_handlers.test.ts @@ -25,11 +25,49 @@ const nameHeader = 'kbn-name'; const allowlistedTestPath = '/xsrf/test/route/whitelisted'; const xsrfDisabledTestPath = '/xsrf/test/route/disabled'; const kibanaName = 'my-kibana-name'; +const internalProductHeader = 'x-elastic-internal-origin'; const setupDeps = { context: contextServiceMock.createSetupContract(), 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']; @@ -247,4 +285,109 @@ describe('core lifecycle handlers', () => { }); }); }); + + describe('restrictInternalRoutes post-auth handler', () => { + const testInternalRoute = '/restrict_internal_routes/test/route_internal'; + const testPublicRoute = '/restrict_internal_routes/test/route_public'; + beforeEach(async () => { + router.get( + { path: testInternalRoute, validate: false, options: { access: 'internal' } }, + (context, req, res) => { + return res.ok({ body: 'ok()' }); + } + ); + router.get( + { path: testPublicRoute, validate: false, options: { access: 'public' } }, + (context, req, res) => { + return res.ok({ body: 'ok()' }); + } + ); + await server.start(); + }); + + it('accepts requests with the internal product header to internal routes', async () => { + await supertest(innerServer.listener) + .get(testInternalRoute) + .set(internalProductHeader, 'anything') + .expect(200, 'ok()'); + }); + + it('accepts requests with the internal product header to public routes', async () => { + await supertest(innerServer.listener) + .get(testPublicRoute) + .set(internalProductHeader, 'anything') + .expect(200, 'ok()'); + }); + }); +}); + +describe('core lifecyle handers 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}`); + }); + server = createHttpServer({ configService }); + + await server.preboot({ context: contextServiceMock.createPrebootContract() }); + const serverSetup = await server.setup(setupDeps); + router = serverSetup.createRouter('/'); + innerServer = serverSetup.server; + }); + + afterEach(async () => { + await server.stop(); + }); + + describe('restrictInternalRoutes postauth handler', () => { + const testInternalRoute = '/restrict_internal_routes/test/route_internal'; + const testPublicRoute = '/restrict_internal_routes/test/route_public'; + beforeEach(async () => { + router.get( + { path: testInternalRoute, validate: false, options: { access: 'internal' } }, + (context, req, res) => { + return res.ok({ body: 'ok()' }); + } + ); + router.get( + { path: testPublicRoute, validate: false, options: { access: 'public' } }, + (context, req, res) => { + return res.ok({ body: 'ok()' }); + } + ); + await server.start(); + }); + + it('request requests without the internal product header to internal routes', async () => { + const result = await supertest(innerServer.listener).get(testInternalRoute).expect(400); + expect(result.body.error).toBe('Bad Request'); + }); + + it('accepts requests with the internal product header to internal routes', async () => { + await supertest(innerServer.listener) + .get(testInternalRoute) + .set(internalProductHeader, 'anything') + .expect(200, 'ok()'); + }); + }); }); diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 34ad6e5acda32..14ebf681c0fec 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -161,6 +161,7 @@ kibana_vars=( server.requestId.allowFromAnyIp server.requestId.ipAllowlist server.rewriteBasePath + server.restrictInternalApis server.securityResponseHeaders.disableEmbedding server.securityResponseHeaders.permissionsPolicy server.securityResponseHeaders.referrerPolicy diff --git a/src/plugins/bfetch/public/plugin.ts b/src/plugins/bfetch/public/plugin.ts index 5e4357139fa7b..2168c023706eb 100644 --- a/src/plugins/bfetch/public/plugin.ts +++ b/src/plugins/bfetch/public/plugin.ts @@ -8,6 +8,7 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; +import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common'; import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './streaming'; import { DISABLE_BFETCH_COMPRESSION, removeLeadingSlash } from '../common'; import { createStreamingBatchedFunction, StreamingBatchedFunctionParams } from './batching'; @@ -83,6 +84,7 @@ export class BfetchPublicPlugin headers: { 'Content-Type': 'application/json', 'kbn-version': version, + [X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana', ...(params.headers || {}), }, getIsCompressionDisabled, diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index 77e5acffc1af3..d5e99d7eea4c0 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -39,7 +39,6 @@ export function fetchStreaming({ if (!isCompressionDisabled) { url = appendQueryParam(url, 'compress', 'true'); } - // Begin the request xhr.open(method, url); xhr.withCredentials = true; diff --git a/src/plugins/bfetch/tsconfig.json b/src/plugins/bfetch/tsconfig.json index dfc8dcab1c1ac..13136e83d88e2 100644 --- a/src/plugins/bfetch/tsconfig.json +++ b/src/plugins/bfetch/tsconfig.json @@ -11,6 +11,7 @@ "@kbn/config-schema", "@kbn/std", "@kbn/core-http-server", + "@kbn/core-http-common", ], "exclude": [ "target/**/*", diff --git a/x-pack/dev-tools/api_debug/request_from_api.js b/x-pack/dev-tools/api_debug/request_from_api.js index ca555f3b62153..1a50232536377 100644 --- a/x-pack/dev-tools/api_debug/request_from_api.js +++ b/x-pack/dev-tools/api_debug/request_from_api.js @@ -9,6 +9,7 @@ import fetch from 'node-fetch'; import { resolve } from 'path'; import abab from 'abab'; import pkg from '../../package.json'; +import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common/src/constants'; function getRequestParams(argv) { // use `--host=https://somedomain.com:5601` or else http://localhost:5601 is defaulted @@ -32,6 +33,7 @@ function getRequestHeaders(auth) { 'kbn-version': pkg.version, 'Content-Type': 'application/json', Authorization: auth, + [X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana', }; } diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index f3e82d45d2e21..47a09297ff866 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -11,7 +11,7 @@ import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; import { canRedirectRequest } from './can_redirect_request'; describe('can_redirect_request', () => { - it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => { + it('returns true if request does not have either a kbn-version or kbn-xsrf header or x-elastic-internal-origin', () => { expect(canRedirectRequest(httpServerMock.createKibanaRequest())).toBe(true); }); @@ -26,6 +26,14 @@ describe('can_redirect_request', () => { expect(canRedirectRequest(request)).toBe(false); }); + it('returns true if request has a x-elastic-internal-origin header', () => { + const request = httpServerMock.createKibanaRequest({ + headers: { 'x-elastic-internal-origin': 'something' }, + }); + + expect(canRedirectRequest(request)).toBe(true); + }); + it('returns false for api routes', () => { expect( canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' })) diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts index cab16e55118ed..32c27c74c9f79 100644 --- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts @@ -27,7 +27,11 @@ describe('UsageStatsClient', () => { return { usageStatsClient, debugLoggerMock, repositoryMock }; }; - const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' }; // as long as these two header fields are truthy, this will be treated like a first-party request + const firstPartyRequestHeaders = { + 'kbn-version': 'a', + referer: 'b', + 'x-elastic-internal-origin': 'c', + }; // as long as these header fields are truthy, this will be treated like a first-party request const incrementOptions = { refresh: false }; describe('#getUsageStats', () => { diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts index 7f30d8981346b..0c2c43eef68a7 100644 --- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts @@ -119,7 +119,9 @@ export class UsageStatsClient { } function getIsKibanaRequest(headers?: Headers) { - // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // The presence of these request headers gives us a good indication that this is a first-party request from the Kibana client. // We can't be 100% certain, but this is a reasonable attempt. - return headers && headers['kbn-version'] && headers.referer; + return ( + headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin'] + ); } diff --git a/x-pack/test/common/services/bsearch_secure.ts b/x-pack/test/common/services/bsearch_secure.ts index 94a5abe73c901..b7bff074afc07 100644 --- a/x-pack/test/common/services/bsearch_secure.ts +++ b/x-pack/test/common/services/bsearch_secure.ts @@ -33,6 +33,7 @@ interface SendOptions { options: object; strategy: string; space?: string; + internalOrigin: string; } export class BsearchSecureService extends FtrService { @@ -43,6 +44,7 @@ export class BsearchSecureService extends FtrService { auth, referer, kibanaVersion, + internalOrigin, options, strategy, space, @@ -74,6 +76,13 @@ export class BsearchSecureService extends FtrService { .set('kbn-version', kibanaVersion) .set('kbn-xsrf', 'true') .send(options); + } else if (internalOrigin) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('x-elastic-internal-origin', internalOrigin) + .set('kbn-xsrf', 'true') + .send(options); } else { result = await supertestWithoutAuth .post(url) @@ -96,6 +105,7 @@ export class BsearchSecureService extends FtrService { .post(`${spaceUrl}/internal/bsearch`) .auth(auth.username, auth.password) .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'Kibana') .send({ batch: [ { diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts index c64f7c78650d2..c663d028cd2ac 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts @@ -66,6 +66,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.LOGS], }, @@ -87,6 +88,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.LOGS], pagination: { @@ -142,6 +144,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.SIEM], }, @@ -163,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS], }, @@ -185,6 +189,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.SIEM], runtimeMappings: { @@ -223,6 +228,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [AlertConsumers.APM], }, @@ -247,6 +253,7 @@ export default ({ getService }: FtrProviderContext) => { }, referer: 'test', kibanaVersion, + internalOrigin: 'Kibana', options: { featureIds: [], },