From cbee18764533aab46bdcb3effb7b3cdd66f83bc5 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 13 Oct 2020 07:27:13 -0400 Subject: [PATCH 1/9] Introduce external url service --- ...lugin-core-public.httpsetup.externalurl.md | 11 + .../kibana-plugin-core-public.httpsetup.md | 1 + ...in-core-public.iexternalurlpolicy.allow.md | 13 ++ ...gin-core-public.iexternalurlpolicy.host.md | 24 ++ ...a-plugin-core-public.iexternalurlpolicy.md | 22 ++ ...core-public.iexternalurlpolicy.protocol.md | 24 ++ .../core/public/kibana-plugin-core-public.md | 1 + ...a-plugin-core-server.iexternalurlconfig.md | 20 ++ ...n-core-server.iexternalurlconfig.policy.md | 13 ++ ...in-core-server.iexternalurlpolicy.allow.md | 13 ++ ...gin-core-server.iexternalurlpolicy.host.md | 24 ++ ...a-plugin-core-server.iexternalurlpolicy.md | 22 ++ ...core-server.iexternalurlpolicy.protocol.md | 24 ++ .../core/server/kibana-plugin-core-server.md | 2 + .../public/http/external_url_service.test.ts | 212 ++++++++++++++++++ src/core/public/http/external_url_service.ts | 98 ++++++++ src/core/public/http/http_service.mock.ts | 3 + src/core/public/http/http_service.ts | 2 + src/core/public/http/types.ts | 6 + src/core/public/index.ts | 2 +- .../injected_metadata_service.mock.ts | 2 + .../injected_metadata_service.ts | 12 + src/core/public/public.api.md | 11 + src/core/server/external_url/config.ts | 92 ++++++++ .../external_url/external_url_config.ts | 90 ++++++++ src/core/server/external_url/index.ts | 23 ++ src/core/server/http/http_config.test.ts | 3 +- src/core/server/http/http_config.ts | 9 +- src/core/server/http/http_service.mock.ts | 2 + src/core/server/http/http_service.test.ts | 2 + src/core/server/http/http_service.ts | 6 +- src/core/server/http/http_tools.test.ts | 2 + src/core/server/http/types.ts | 2 + src/core/server/index.ts | 1 + src/core/server/legacy/legacy_service.ts | 6 +- .../rendering_service.test.ts.snap | 45 ++++ .../server/rendering/rendering_service.tsx | 1 + src/core/server/rendering/types.ts | 2 + src/core/server/server.api.md | 12 + src/core/server/server.ts | 2 + src/core/server/types.ts | 1 + .../dashboard_empty_screen.test.tsx.snap | 9 + .../__snapshots__/flyout.test.tsx.snap | 3 + ...telemetry_management_section.test.tsx.snap | 3 + .../api_keys/api_keys_management_app.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 6 +- .../roles/roles_management_app.test.tsx | 8 +- .../users/users_management_app.test.tsx | 6 +- 48 files changed, 883 insertions(+), 17 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md create mode 100644 src/core/public/http/external_url_service.test.ts create mode 100644 src/core/public/http/external_url_service.ts create mode 100644 src/core/server/external_url/config.ts create mode 100644 src/core/server/external_url/external_url_config.ts create mode 100644 src/core/server/external_url/index.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md new file mode 100644 index 0000000000000..b26c9d371e496 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [HttpSetup](./kibana-plugin-core-public.httpsetup.md) > [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) + +## HttpSetup.externalUrl property + +Signature: + +```typescript +externalUrl: IExternalUrl; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md index bb43e9f588a72..b8a99cbb62353 100644 --- a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md @@ -18,6 +18,7 @@ export interface HttpSetup | [anonymousPaths](./kibana-plugin-core-public.httpsetup.anonymouspaths.md) | IAnonymousPaths | APIs for denoting certain paths for not requiring authentication | | [basePath](./kibana-plugin-core-public.httpsetup.basepath.md) | IBasePath | APIs for manipulating the basePath on URL segments. See [IBasePath](./kibana-plugin-core-public.ibasepath.md) | | [delete](./kibana-plugin-core-public.httpsetup.delete.md) | HttpHandler | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | +| [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) | IExternalUrl | | | [fetch](./kibana-plugin-core-public.httpsetup.fetch.md) | HttpHandler | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [get](./kibana-plugin-core-public.httpsetup.get.md) | HttpHandler | Makes an HTTP request with the GET method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [head](./kibana-plugin-core-public.httpsetup.head.md) | HttpHandler | Makes an HTTP request with the HEAD method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..a9d773f0a206f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates of this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..5551d52cc1226 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md new file mode 100644 index 0000000000000..5bd04d041da0b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates of this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..67b9b439a54f6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8b1bdcdee3be..332d70c3127e6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -73,6 +73,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IAnonymousPaths](./kibana-plugin-core-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-core-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | | [IContextContainer](./kibana-plugin-core-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IHttpFetchError](./kibana-plugin-core-public.ihttpfetcherror.md) | | | [IHttpInterceptController](./kibana-plugin-core-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-core-public.httpinterceptor.md). | | [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md new file mode 100644 index 0000000000000..8df4db4aa9b5e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) + +## IExternalUrlConfig interface + +External Url configuration for use in Kibana. + +Signature: + +```typescript +export interface IExternalUrlConfig +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) | IExternalUrlPolicy[] | A set of policies describing which external urls are allowed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md new file mode 100644 index 0000000000000..b5b6f07038076 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) > [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) + +## IExternalUrlConfig.policy property + +A set of policies describing which external urls are allowed. + +Signature: + +```typescript +readonly policy: IExternalUrlPolicy[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..e0c140409dcf0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates of this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..e65de074f1578 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md new file mode 100644 index 0000000000000..8e3658a10ed81 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) | boolean | Indicates of this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..00c5d05eb0cc4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index adbb2460dc80a..0407e42a9fccc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -94,6 +94,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [ICspConfig](./kibana-plugin-core-server.icspconfig.md) | CSP configuration for use in Kibana. | | [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) | See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | +| [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) | External Url configuration for use in Kibana. | +| [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [ImageValidation](./kibana-plugin-core-server.imagevalidation.md) | | diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts new file mode 100644 index 0000000000000..8aae656d22b7d --- /dev/null +++ b/src/core/public/http/external_url_service.test.ts @@ -0,0 +1,212 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExternalUrlConfig } from 'src/core/server/types'; + +import { injectedMetadataServiceMock } from '../mocks'; + +import { ExternalUrlService } from './external_url_service'; + +const setupService = ({ + location, + serverBasePath, + policy, +}: { + location: URL; + serverBasePath: string; + policy: ExternalUrlConfig['policy']; +}) => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy }); + injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); + + const service = new ExternalUrlService(); + return { + setup: service.setup({ + injectedMetadata, + location, + }), + }; +}; + +describe('External Url Service', () => { + describe('#validateUrl', () => { + describe('internal requests with a server base path', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + it('allows relative URLs that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + + it('disallows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('disallows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + }); + + describe('internal requests without a server base path', () => { + const serverBasePath = ''; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + it('allows relative URLs', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + }); + + describe('external requests', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + it('does not allow external urls by default', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `http://www.google.com`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with a matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('disallows external urls with a matching host and unmatched protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `http://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with a matching host and any protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `ftp://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with any host and matching protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + }); + }); +}); diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts new file mode 100644 index 0000000000000..09b5ff23f5cf6 --- /dev/null +++ b/src/core/public/http/external_url_service.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IExternalUrlPolicy } from 'src/core/server/types'; + +import { CoreService } from 'src/core/types'; +import { InjectedMetadataSetup } from '../injected_metadata'; +import { IExternalUrl } from './types'; + +interface SetupDeps { + location: Pick; + injectedMetadata: InjectedMetadataSetup; +} + +const isHostMatch = (actualHost: string, ruleHost: string) => { + const hostParts = actualHost.split('.').reverse(); + const ruleParts = ruleHost.split('.').reverse(); + + return ruleParts.every((part, idx) => part === hostParts[idx]); +}; + +const isProtocolMatch = (actualProtocol: string, ruleProtocol: string) => { + return normalizeProtocol(actualProtocol) === normalizeProtocol(ruleProtocol); +}; + +function normalizeProtocol(protocol: string) { + return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase(); +} + +const createExternalUrlValidation = ( + rules: IExternalUrlPolicy[], + location: Pick, + serverBasePath: string +) => { + const base = new URL(location.origin + serverBasePath); + return function validateExternalUrl(next: string) { + const url = new URL(next, base); + + const isInternalURL = + url.origin === base.origin && + (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`)); + + if (isInternalURL) { + return url; + } + + // Accumulator has three potential values here: + // True => allow request, don't check other rules + // False => reject request, don't check other rules + // Undefined => Not yet known, proceed to next rule + const allowed = rules.reduce((result: boolean | undefined, rule) => { + if (typeof result === 'boolean') { + return result; + } + + const hostMatch = rule.host ? isHostMatch(url.hostname || '', rule.host) : true; + + const protocolMatch = rule.protocol ? isProtocolMatch(url.protocol, rule.protocol) : true; + + const isRuleMatch = hostMatch && protocolMatch; + + return isRuleMatch ? rule.allow : undefined; + }, undefined); + + return allowed === true ? url : null; + }; +}; + +export class ExternalUrlService implements CoreService { + setup({ injectedMetadata, location }: SetupDeps): IExternalUrl { + const serverBasePath = injectedMetadata.getServerBasePath(); + const { policy } = injectedMetadata.getExternalUrlConfig(); + + return { + validateUrl: createExternalUrlValidation(policy, location, serverBasePath), + }; + } + + start() {} + + stop() {} +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 68533159765fb..025336487c855 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -41,6 +41,9 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ register: jest.fn(), isAnonymous: jest.fn(), }, + externalUrl: { + validateUrl: jest.fn(), + }, addLoadingCountSource: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), intercept: jest.fn(), diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 98de1d919c481..bb42bfeb0c41e 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -25,6 +25,7 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { ExternalUrlService } from './external_url_service'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -50,6 +51,7 @@ export class HttpService implements CoreService { this.service = { basePath, anonymousPaths: this.anonymousPaths.setup({ basePath }), + externalUrl: new ExternalUrlService().setup({ injectedMetadata, location: window.location }), intercept: fetchService.intercept.bind(fetchService), fetch: fetchService.fetch.bind(fetchService), delete: fetchService.delete.bind(fetchService), diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 2361d981b8597..d87c43a09c601 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -33,6 +33,8 @@ export interface HttpSetup { */ anonymousPaths: IAnonymousPaths; + externalUrl: IExternalUrl; + /** * Adds a new {@link HttpInterceptor} to the global HTTP client. * @param interceptor a {@link HttpInterceptor} @@ -104,6 +106,10 @@ export interface IBasePath { readonly serverBasePath: string; } +export interface IExternalUrl { + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} + /** * APIs for denoting paths as not requiring authentication */ diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 1393e69d55e51..400f7a7d11a70 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,7 +77,7 @@ import { HandlerParameters, } from './context'; -export { PackageInfo, EnvironmentMode } from '../server/types'; +export { PackageInfo, EnvironmentMode, IExternalUrlPolicy } from '../server/types'; export { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export { diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 33d04eedebb07..0097e88c7e6ec 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -26,6 +26,7 @@ const createSetupContractMock = () => { getKibanaVersion: jest.fn(), getKibanaBranch: jest.fn(), getCspConfig: jest.fn(), + getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), getLegacyMetadata: jest.fn(), getPlugins: jest.fn(), @@ -34,6 +35,7 @@ const createSetupContractMock = () => { getKibanaBuildNumber: jest.fn(), }; setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); + setupContract.getExternalUrlConfig.mockReturnValue({ policy: [] }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); setupContract.getAnonymousStatusPage.mockReturnValue(false); setupContract.getLegacyMetadata.mockReturnValue({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index bd8c9e91f15a2..b3880c1f39635 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -22,6 +22,8 @@ import { deepFreeze } from '@kbn/std'; import { DiscoveredPlugin, PluginName } from '../../server'; import { EnvironmentMode, + ExternalUrlConfig, + IExternalUrlPolicy, PackageInfo, UiSettingsParams, UserProvidedValues, @@ -48,6 +50,9 @@ export interface InjectedMetadataParams { csp: { warnLegacyBrowsers: boolean; }; + externalUrl: { + policy: ExternalUrlConfig['policy']; + }; vars: { [key: string]: unknown; }; @@ -107,6 +112,10 @@ export class InjectedMetadataService { return this.state.csp; }, + getExternalUrlConfig: () => { + return this.state.externalUrl; + }, + getPlugins: () => { return this.state.uiPlugins; }, @@ -148,6 +157,9 @@ export interface InjectedMetadataSetup { getCspConfig: () => { warnLegacyBrowsers: boolean; }; + getExternalUrlConfig: () => { + policy: IExternalUrlPolicy[]; + }; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 28a20845426d9..5bf50824e16c8 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -706,6 +706,10 @@ export interface HttpSetup { anonymousPaths: IAnonymousPaths; basePath: IBasePath; delete: HttpHandler; + // Warning: (ae-forgotten-export) The symbol "IExternalUrl" needs to be exported by the entry point index.d.ts + // + // (undocumented) + externalUrl: IExternalUrl; fetch: HttpHandler; get: HttpHandler; getLoadingCount$(): Observable; @@ -755,6 +759,13 @@ export interface IContextContainer> { // @public export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: PartialExceptFor, 'core'>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public (undocumented) export interface IHttpFetchError extends Error { // (undocumented) diff --git a/src/core/server/external_url/config.ts b/src/core/server/external_url/config.ts new file mode 100644 index 0000000000000..293e14b974618 --- /dev/null +++ b/src/core/server/external_url/config.ts @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; +import { IExternalUrlPolicy } from '.'; + +/** + * @internal + */ +export type ExternalUrlConfigType = TypeOf; + +const allowSchema = schema.boolean(); + +const hostSchema = schema.string(); + +const protocolSchema = schema.string({ + validate: (value) => { + if (value.indexOf('/') >= 0) { + throw new Error(`protocols should not include slashes`); + } + }, +}); + +const policySchema = schema.oneOf([ + schema.object({ + allow: allowSchema, + host: hostSchema, + }), + schema.object({ + allow: allowSchema, + protocol: protocolSchema, + }), + schema.object({ + allow: allowSchema, + protocol: protocolSchema, + host: hostSchema, + }), +]); + +schema.object( + { + allow: schema.boolean(), + protocol: schema.maybe( + schema.string({ + validate: (value) => { + if (value.indexOf('/') >= 0) { + throw new Error(`protocols should not include slashes`); + } + }, + }) + ), + host: schema.maybe(schema.string()), + }, + { + validate: (value) => { + if (!value.host && !value.protocol) { + throw new Error(`policy must include a 'host', 'protocol', or both.`); + } + }, + } +); + +export const config = { + path: 'externalUrl', + schema: schema.object({ + policy: schema.arrayOf(policySchema, { + defaultValue: [ + { + host: '*', + protocol: '*', + allow: true, + }, + ], + }), + }), +}; diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts new file mode 100644 index 0000000000000..7480366819926 --- /dev/null +++ b/src/core/server/external_url/external_url_config.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { config } from './config'; + +const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); + +/** + * External Url configuration for use in Kibana. + * @public + */ +export interface IExternalUrlConfig { + /** + * A set of policies describing which external urls are allowed. + */ + readonly policy: IExternalUrlPolicy[]; +} + +/** + * A policy describing whether access to an external destination is allowed. + * @public + */ +export interface IExternalUrlPolicy { + /** + * Indicates of this policy allows or denies access to the described destination. + */ + allow: boolean; + + /** + * Optional host describing the external destination. + * May be combined with `protocol`. Required if `protocol` is not defined. + * + * @example + * ```ts + * // allows access to all of google.com, using any protocol. + * allow: true, + * host: 'google.com' + * ``` + */ + host?: string; + + /** + * Optional protocol describing the external destination. + * May be combined with `host`. Required if `host` is not defined. + * + * @example + * ```ts + * // allows access to all destinations over the `https` protocol. + * allow: true, + * protocol: 'https' + * ``` + */ + protocol?: string; +} + +/** + * External Url configuration for use in Kibana. + * @public + */ +export class ExternalUrlConfig implements IExternalUrlConfig { + static readonly DEFAULT = new ExternalUrlConfig(); + + public readonly policy: IExternalUrlPolicy[]; + + /** + * Returns the default External Url configuration when passed with no config + * @internal + */ + constructor(rawConfig: Partial = {}) { + const source = { ...DEFAULT_CONFIG, ...rawConfig }; + + this.policy = (source.policy as unknown) as IExternalUrlPolicy[]; + } +} diff --git a/src/core/server/external_url/index.ts b/src/core/server/external_url/index.ts new file mode 100644 index 0000000000000..ac1c0a6dc2c9f --- /dev/null +++ b/src/core/server/external_url/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExternalUrlConfig, IExternalUrlConfig, IExternalUrlPolicy } from './external_url_config'; +import { ExternalUrlConfigType, config } from './config'; + +export { ExternalUrlConfig, ExternalUrlConfigType, config, IExternalUrlConfig, IExternalUrlPolicy }; diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 58e6699582e13..36b101cd9c555 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -20,6 +20,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; import { CspConfig } from '../csp'; +import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; @@ -276,7 +277,7 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT); + const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 7d41b4ea9e915..c8fd170adfb90 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -21,6 +21,7 @@ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; import { hostname } from 'os'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; +import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url'; import { SslConfig, sslSchema } from './ssl_config'; const validBasePathRegex = /^\/.*[^\/]$/; @@ -142,13 +143,18 @@ export class HttpConfig { public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; + public externalUrl: IExternalUrlConfig; public xsrf: { disableProtection: boolean; whitelist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; /** * @internal */ - constructor(rawHttpConfig: HttpConfigType, rawCspConfig: CspConfigType) { + constructor( + rawHttpConfig: HttpConfigType, + rawCspConfig: CspConfigType, + rawExternalUrlConfig: ExternalUrlConfig + ) { this.autoListen = rawHttpConfig.autoListen; this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; @@ -171,6 +177,7 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); + this.externalUrl = new ExternalUrlConfig(rawExternalUrlConfig); this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 4fc972c9679bb..3bd5289c1097b 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -37,6 +37,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { configMock } from '../config/mocks'; +import { ExternalUrlConfig } from '../external_url'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; @@ -101,6 +102,7 @@ const createInternalSetupContractMock = () => { registerStaticDir: jest.fn(), basePath: createBasePathMock(), csp: CspConfig.DEFAULT, + externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), getAuthHeaders: jest.fn(), getServerInfo: jest.fn(), diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 11cea88fa0dd2..a30791476a175 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -30,6 +30,7 @@ import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { config as cspConfig } from '../csp'; +import { config as externalUrlConfig } from '../external_url'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -48,6 +49,7 @@ const createConfigService = (value: Partial = {}) => { ); configService.setSchema(config.path, config.schema); configService.setSchema(cspConfig.path, cspConfig.schema); + configService.setSchema(externalUrlConfig.path, externalUrlConfig.schema); return configService; }; const contextSetup = contextServiceMock.createSetupContract(); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 0127a6493e7fd..a718aaad4967c 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -44,6 +44,7 @@ import { import { RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; +import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; interface SetupDeps { context: ContextSetup; @@ -73,7 +74,8 @@ export class HttpService this.config$ = combineLatest([ configService.atPath(httpConfig.path), configService.atPath(cspConfig.path), - ]).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(externalUrlConfig.path), + ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); this.httpServer = new HttpServer(logger, 'Kibana'); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -103,6 +105,8 @@ export class HttpService this.internalSetup = { ...serverContract, + externalUrl: config.externalUrl, + createRouter: (path: string, pluginId: PluginOpaqueId = this.coreContext.coreId) => { const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); const router = new Router(path, this.log, enhanceHandler); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 1423e27b914a3..a409a7485a0ef 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -136,6 +136,7 @@ describe('getServerOptions', () => { certificate: 'some-certificate-path', }, }), + {} as any, {} as any ); @@ -165,6 +166,7 @@ describe('getServerOptions', () => { clientAuthentication: 'required', }, }), + {} as any, {} as any ); diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 4345783e46e11..db7a0550df9b0 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -30,6 +30,7 @@ import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IBasePath } from './base_path_service'; +import { ExternalUrlConfig } from '../external_url'; import { PluginOpaqueId, RequestHandlerContext } from '..'; /** @@ -280,6 +281,7 @@ export interface InternalHttpServiceSetup extends Omit { auth: HttpServerSetup['auth']; server: HttpServerSetup['server']; + externalUrl: ExternalUrlConfig; createRouter: (path: string, plugin?: PluginOpaqueId) => IRouter; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 7b19c3a686757..e7fcaea888281 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -129,6 +129,7 @@ export { DeleteDocumentResponse, } from './elasticsearch'; export * from './elasticsearch/legacy/api_types'; +export { IExternalUrlConfig, IExternalUrlPolicy } from './external_url'; export { AuthenticationHandler, AuthHeaders, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 3111c8daf7981..3742565e492c7 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -32,6 +32,7 @@ import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } from '../http'; import { Logger } from '../logging'; import { LegacyServiceSetupDeps, LegacyServiceStartDeps, LegacyConfig, LegacyVars } from './types'; +import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; import { CoreSetup, CoreStart } from '..'; interface LegacyKbnServer { @@ -84,8 +85,9 @@ export class LegacyService implements CoreService { .pipe(map((rawConfig) => new DevConfig(rawConfig))); this.httpConfig$ = combineLatest( configService.atPath(httpConfig.path), - configService.atPath(cspConfig.path) - ).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(cspConfig.path), + configService.atPath(externalUrlConfig.path) + ).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); } public async setupLegacyConfig() { diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 07ca59a48c6b0..384acba24b617 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -23,6 +23,15 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + "host": "*", + "protocol": "*", + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -66,6 +75,15 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + "host": "*", + "protocol": "*", + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -109,6 +127,15 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + "host": "*", + "protocol": "*", + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -156,6 +183,15 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + "host": "*", + "protocol": "*", + }, + ], + }, "i18n": Object { "translationsUrl": "/translations/en.json", }, @@ -199,6 +235,15 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + "host": "*", + "protocol": "*", + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 738787f940905..20ba710ff49b9 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -78,6 +78,7 @@ export class RenderingService { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, + externalUrl: { policy: http.externalUrl.policy }, vars: vars ?? {}, uiPlugins: await Promise.all( [...uiPlugins.public].map(async ([id, plugin]) => ({ diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 75bbac1a243e9..34d27fd41f3e6 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -25,6 +25,7 @@ import { InternalHttpServiceSetup, KibanaRequest, LegacyRequest } from '../http' import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; import type { InternalStatusServiceSetup } from '../status'; +import { IExternalUrlConfig } from '../external_url'; /** @internal */ export interface RenderingMetadata { @@ -49,6 +50,7 @@ export interface RenderingMetadata { translationsUrl: string; }; csp: Pick; + externalUrl: Pick; vars: Record; uiPlugins: Array<{ id: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index a03e5ec9acd27..11e3a94e14996 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -895,6 +895,18 @@ export interface ICustomClusterClient extends IClusterClient { close: () => Promise; } +// @public +export interface IExternalUrlConfig { + readonly policy: IExternalUrlPolicy[]; +} + +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public export interface IKibanaResponse { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 0f7e8cced999c..e417f64b9b8d1 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -53,6 +53,7 @@ import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { config as externalUrlConfig } from './external_url'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -301,6 +302,7 @@ export class Server { pathConfig, cspConfig, elasticsearchConfig, + externalUrlConfig, loggingConfig, httpConfig, pluginsConfig, diff --git a/src/core/server/types.ts b/src/core/server/types.ts index f8d2f635671fa..48b3a9058605c 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,3 +23,4 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export type { EnvironmentMode, PackageInfo } from '@kbn/config'; +export type { ExternalUrlConfig, IExternalUrlPolicy } from './external_url'; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index 31ef011835917..b310ce7251177 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -17,6 +17,9 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -380,6 +383,9 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -751,6 +757,9 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index 3fbacef99806d..89e01e44dd1e1 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -179,6 +179,9 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index b1921452354d2..57456d5f1173f 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -320,6 +320,9 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 30c5f8a361b42..ca370271b4360 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -43,7 +43,7 @@ describe('apiKeysManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]); expect(container).toMatchInlineSnapshot(`
- Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}}} + Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}}
`); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index e65310ba399ea..b39aaa045732b 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -54,7 +54,7 @@ describe('roleMappingsManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Role Mappings' }]); expect(container).toMatchInlineSnapshot(`
- Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -73,7 +73,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -94,7 +94,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"name":"someRoleMappingName","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleMappingName","search":"","hash":""}}} + Role Mapping Edit Page: {"name":"someRoleMappingName","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleMappingName","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index c45528399db99..02a0458f85d9c 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -71,7 +71,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }]); expect(container).toMatchInlineSnapshot(`
- Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -87,7 +87,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -108,7 +108,7 @@ describe('rolesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleName","search":"","hash":""}}} + Role Edit Page: {"action":"edit","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someRoleName","search":"","hash":""}}}
`); @@ -126,7 +126,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}} + Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 06bd2eff6aa1e..b1cd2068bc06e 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -60,7 +60,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }]); expect(container).toMatchInlineSnapshot(`
- Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -76,7 +76,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -97,7 +97,7 @@ describe('usersManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"someUserName","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someUserName","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"username":"someUserName","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/someUserName","search":"","hash":""}}}
`); From da2bf289bf86bd4f56239329c3df80c011b024e0 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 1 Dec 2020 13:53:35 -0500 Subject: [PATCH 2/9] Start addressing PR feedback --- .../kibana-plugin-core-public.iexternalurl.md | 20 +++ ...in-core-public.iexternalurl.validateurl.md | 26 ++++ ...in-core-public.iexternalurlpolicy.allow.md | 2 +- ...a-plugin-core-public.iexternalurlpolicy.md | 2 +- .../core/public/kibana-plugin-core-public.md | 1 + .../public/http/external_url_service.test.ts | 79 ++++++++++ src/core/public/http/external_url_service.ts | 17 +-- src/core/public/http/types.ts | 15 +- src/core/public/index.ts | 1 + .../injected_metadata_service.ts | 3 +- src/core/public/public.api.md | 7 +- src/core/server/external_url/config.test.ts | 142 ++++++++++++++++++ src/core/server/external_url/config.ts | 50 ++---- .../external_url/external_url_config.ts | 4 +- src/core/server/external_url/index.ts | 6 +- .../server/rendering/rendering_service.tsx | 2 +- src/core/server/rendering/types.ts | 4 +- 17 files changed, 319 insertions(+), 62 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md create mode 100644 src/core/server/external_url/config.test.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md new file mode 100644 index 0000000000000..5a598281c7be7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) + +## IExternalUrl interface + +APIs for working with external URLs. + +Signature: + +```typescript +export interface IExternalUrl +``` + +## Methods + +| Method | Description | +| --- | --- | +| [validateUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.validateurl.md) | Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.If the URL is valid, then a URL will be returned. Otherwise, this will return null. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md new file mode 100644 index 0000000000000..466d7cfebf547 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) > [validateUrl](./kibana-plugin-core-public.iexternalurl.validateurl.md) + +## IExternalUrl.validateUrl() method + +Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml. + +If the URL is valid, then a URL will be returned. Otherwise, this will return null. + +Signature: + +```typescript +validateUrl(relativeOrAbsoluteUrl: string): URL | null; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| relativeOrAbsoluteUrl | string | | + +Returns: + +`URL | null` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md index a9d773f0a206f..ec7129a43b99a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md @@ -4,7 +4,7 @@ ## IExternalUrlPolicy.allow property -Indicates of this policy allows or denies access to the described destination. +Indicates if this policy allows or denies access to the described destination. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md index 5bd04d041da0b..a87dc69d79e23 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -16,7 +16,7 @@ export interface IExternalUrlPolicy | Property | Type | Description | | --- | --- | --- | -| [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates of this policy allows or denies access to the described destination. | +| [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates if this policy allows or denies access to the described destination. | | [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | | [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 3a3852c11cb02..a3df5d30137df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -73,6 +73,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IAnonymousPaths](./kibana-plugin-core-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-core-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | | [IContextContainer](./kibana-plugin-core-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) | APIs for working with external URLs. | | [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IHttpFetchError](./kibana-plugin-core-public.ihttpfetcherror.md) | | | [IHttpInterceptController](./kibana-plugin-core-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-core-public.httpinterceptor.md). | diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index 8aae656d22b7d..de757ad0de952 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -94,6 +94,36 @@ describe('External Url Service', () => { expect(result).toBeNull(); }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('does allow relative URLs that include a host if allowed by policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual( + `https://www.google.com${serverBasePath}${urlCandidate}` + ); + }); + }); }); describe('internal requests without a server base path', () => { @@ -119,6 +149,34 @@ describe('External Url Service', () => { expect(result).toBeInstanceOf(URL); expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('allows relative URLs that include a host in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`https://www.google.com${urlCandidate}`); + }); + }); }); describe('external requests', () => { @@ -207,6 +265,27 @@ describe('External Url Service', () => { expect(result).toBeInstanceOf(URL); expect(result?.toString()).toEqual(urlCandidate); }); + + it('disallows external urls that match multiple rules, one of which denies the request', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + { + allow: false, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); }); }); }); diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index 09b5ff23f5cf6..71992a79e8f3e 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -60,23 +60,18 @@ const createExternalUrlValidation = ( return url; } - // Accumulator has three potential values here: - // True => allow request, don't check other rules - // False => reject request, don't check other rules - // Undefined => Not yet known, proceed to next rule - const allowed = rules.reduce((result: boolean | undefined, rule) => { - if (typeof result === 'boolean') { - return result; - } - + let allowed: null | boolean = null; + rules.forEach((rule) => { const hostMatch = rule.host ? isHostMatch(url.hostname || '', rule.host) : true; const protocolMatch = rule.protocol ? isProtocolMatch(url.protocol, rule.protocol) : true; const isRuleMatch = hostMatch && protocolMatch; - return isRuleMatch ? rule.allow : undefined; - }, undefined); + if (isRuleMatch && allowed !== false) { + allowed = rule.allow; + } + }); return allowed === true ? url : null; }; diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index d87c43a09c601..532d234fde986 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -105,8 +105,21 @@ export interface IBasePath { */ readonly serverBasePath: string; } - +/** + * APIs for working with external URLs. + * + * @public + */ export interface IExternalUrl { + /** + * Determines if the provided URL is a valid location to send users. + * Validation is based on the configured allow list in kibana.yml. + * + * If the URL is valid, then a URL will be returned. + * Otherwise, this will return null. + * + * @param relativeOrAbsoluteUrl + */ validateUrl(relativeOrAbsoluteUrl: string): URL | null; } diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 6bffdf93c6190..8e240bfe91d48 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -164,6 +164,7 @@ export { HttpHandler, IBasePath, IAnonymousPaths, + IExternalUrl, IHttpInterceptController, IHttpFetchError, IHttpResponseInterceptorOverrides, diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index b3880c1f39635..e3190fe8c74d1 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -22,7 +22,6 @@ import { deepFreeze } from '@kbn/std'; import { DiscoveredPlugin, PluginName } from '../../server'; import { EnvironmentMode, - ExternalUrlConfig, IExternalUrlPolicy, PackageInfo, UiSettingsParams, @@ -51,7 +50,7 @@ export interface InjectedMetadataParams { warnLegacyBrowsers: boolean; }; externalUrl: { - policy: ExternalUrlConfig['policy']; + policy: IExternalUrlPolicy[]; }; vars: { [key: string]: unknown; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 50e1bb1db5417..c8f2a4b7a9c3c 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -720,8 +720,6 @@ export interface HttpSetup { anonymousPaths: IAnonymousPaths; basePath: IBasePath; delete: HttpHandler; - // Warning: (ae-forgotten-export) The symbol "IExternalUrl" needs to be exported by the entry point index.d.ts - // // (undocumented) externalUrl: IExternalUrl; fetch: HttpHandler; @@ -773,6 +771,11 @@ export interface IContextContainer> { // @public export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: PartialExceptFor, 'core'>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; +// @public +export interface IExternalUrl { + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} + // @public export interface IExternalUrlPolicy { allow: boolean; diff --git a/src/core/server/external_url/config.test.ts b/src/core/server/external_url/config.test.ts new file mode 100644 index 0000000000000..ad62003fdca8a --- /dev/null +++ b/src/core/server/external_url/config.test.ts @@ -0,0 +1,142 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { config } from './config'; + +describe('externalUrl config', () => { + it('allows an empty policy', () => { + expect(config.schema.validate({ policy: [] })).toMatchInlineSnapshot(` + Object { + "policy": Array [], + } + `); + }); + + it('allows a policy with just a protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "protocol": "http", + }, + ], + } + `); + }); + + it('allows a policy with just a host', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + }, + ], + } + `); + }); + + it('allows a policy with both host and protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + "protocol": "http", + }, + ], + } + `); + }); + + it('requires either a host or protocol', () => { + expect(() => + config.schema.validate({ + policy: [ + { + allow: true, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[policy.0]: policy must include a 'host', 'protocol', or both."` + ); + }); + + describe('protocols', () => { + ['http', 'https', 'ftp', 'ftps', 'custom-protocol+123.bar'].forEach((protocol) => { + it(`allows a protocol of "${protocol}"`, () => { + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }); + }); + }); + + ['1http', '', 'custom-protocol()', 'https://'].forEach((protocol) => { + it(`disallows a protocol of "${protocol}"`, () => { + expect(() => + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }) + ).toThrowError(); + }); + }); + }); +}); diff --git a/src/core/server/external_url/config.ts b/src/core/server/external_url/config.ts index 293e14b974618..46f7ce4352b4d 100644 --- a/src/core/server/external_url/config.ts +++ b/src/core/server/external_url/config.ts @@ -31,45 +31,25 @@ const hostSchema = schema.string(); const protocolSchema = schema.string({ validate: (value) => { - if (value.indexOf('/') >= 0) { - throw new Error(`protocols should not include slashes`); - } + // tools.ietf.org/html/rfc3986#section-3.1 + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + const schemaRegex = /^[a-zA-Z][a-zA-Z0-9\+\-\.]*$/; + if (!schemaRegex.test(value)) + throw new Error( + 'Protocol must begin with a letter, and can only contain letters, numbers, and the following characters: `+ - .`' + ); }, }); -const policySchema = schema.oneOf([ - schema.object({ - allow: allowSchema, - host: hostSchema, - }), - schema.object({ - allow: allowSchema, - protocol: protocolSchema, - }), - schema.object({ - allow: allowSchema, - protocol: protocolSchema, - host: hostSchema, - }), -]); - -schema.object( +const policySchema = schema.object( { - allow: schema.boolean(), - protocol: schema.maybe( - schema.string({ - validate: (value) => { - if (value.indexOf('/') >= 0) { - throw new Error(`protocols should not include slashes`); - } - }, - }) - ), - host: schema.maybe(schema.string()), + allow: allowSchema, + protocol: schema.maybe(protocolSchema), + host: schema.maybe(hostSchema), }, { - validate: (value) => { - if (!value.host && !value.protocol) { + validate: ({ protocol, host }) => { + if (!host && !protocol) { throw new Error(`policy must include a 'host', 'protocol', or both.`); } }, @@ -82,9 +62,9 @@ export const config = { policy: schema.arrayOf(policySchema, { defaultValue: [ { - host: '*', - protocol: '*', allow: true, + protocol: '*', + host: '*', }, ], }), diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index 7480366819926..98d7ea9b2b4c5 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -38,7 +38,7 @@ export interface IExternalUrlConfig { */ export interface IExternalUrlPolicy { /** - * Indicates of this policy allows or denies access to the described destination. + * Indicates if this policy allows or denies access to the described destination. */ allow: boolean; @@ -85,6 +85,6 @@ export class ExternalUrlConfig implements IExternalUrlConfig { constructor(rawConfig: Partial = {}) { const source = { ...DEFAULT_CONFIG, ...rawConfig }; - this.policy = (source.policy as unknown) as IExternalUrlPolicy[]; + this.policy = source.policy; } } diff --git a/src/core/server/external_url/index.ts b/src/core/server/external_url/index.ts index ac1c0a6dc2c9f..dfc8e753fa644 100644 --- a/src/core/server/external_url/index.ts +++ b/src/core/server/external_url/index.ts @@ -17,7 +17,5 @@ * under the License. */ -import { ExternalUrlConfig, IExternalUrlConfig, IExternalUrlPolicy } from './external_url_config'; -import { ExternalUrlConfigType, config } from './config'; - -export { ExternalUrlConfig, ExternalUrlConfigType, config, IExternalUrlConfig, IExternalUrlPolicy }; +export { ExternalUrlConfig, IExternalUrlConfig, IExternalUrlPolicy } from './external_url_config'; +export { ExternalUrlConfigType, config } from './config'; diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 20ba710ff49b9..64496785cdd25 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -78,7 +78,7 @@ export class RenderingService { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, - externalUrl: { policy: http.externalUrl.policy }, + externalUrl: http.externalUrl, vars: vars ?? {}, uiPlugins: await Promise.all( [...uiPlugins.public].map(async ([id, plugin]) => ({ diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 34d27fd41f3e6..72a5c3e4ba7d6 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -25,7 +25,7 @@ import { InternalHttpServiceSetup, KibanaRequest, LegacyRequest } from '../http' import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; import type { InternalStatusServiceSetup } from '../status'; -import { IExternalUrlConfig } from '../external_url'; +import { IExternalUrlPolicy } from '../external_url'; /** @internal */ export interface RenderingMetadata { @@ -50,7 +50,7 @@ export interface RenderingMetadata { translationsUrl: string; }; csp: Pick; - externalUrl: Pick; + externalUrl: { policy: IExternalUrlPolicy[] }; vars: Record; uiPlugins: Array<{ id: string; From df72e26ad6692ed0bc78c0e5f3d4bf3390d4beed Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 2 Dec 2020 14:52:12 -0500 Subject: [PATCH 3/9] Addressing additional review feedback --- .../public/http/external_url_service.test.ts | 194 +++++++++++++----- src/core/server/external_url/config.test.ts | 28 ++- src/core/server/external_url/config.ts | 21 +- .../external_url/external_url_config.ts | 13 +- .../rendering_service.test.ts.snap | 10 - 5 files changed, 178 insertions(+), 88 deletions(-) diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index de757ad0de952..24ce15f202f80 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -45,6 +45,32 @@ const setupService = ({ }; }; +const internalRequestScenarios = [ + { + description: 'without any policies', + allowExternal: false, + policy: [], + }, + { + description: 'with an unrestricted policy', + allowExternal: true, + policy: [ + { + allow: true, + }, + ], + }, + { + description: 'with a fully restricted policy', + allowExternal: false, + policy: [ + { + allow: false, + }, + ], + }, +]; + describe('External Url Service', () => { describe('#validateUrl', () => { describe('internal requests with a server base path', () => { @@ -53,46 +79,79 @@ describe('External Url Service', () => { const kibanaRoot = `${serverRoot}${serverBasePath}`; const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); - it('allows relative URLs that start with the server base path', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `/some/path?foo=bar`; - const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); - - expect(result).toBeInstanceOf(URL); - expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); - }); - - it('allows absolute URLs to Kibana that start with the server base path', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; - const result = setup.validateUrl(urlCandidate); - - expect(result).toBeInstanceOf(URL); - expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); - }); - - it('disallows relative URLs that do not start with the server base path', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `/some/path?foo=bar`; - const result = setup.validateUrl(urlCandidate); - - expect(result).toBeNull(); - }); + internalRequestScenarios.forEach(({ description, policy, allowExternal }) => { + describe(description, () => { + it('allows relative URLs that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); - it('disallows absolute URLs to Kibana that do not start with the server base path', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `${serverRoot}/some/path?foo=bar`; - const result = setup.validateUrl(urlCandidate); + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); - expect(result).toBeNull(); - }); + it('allows absolute URLs to Kibana that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); - it('disallows relative URLs that attempt to bypass the server base path', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `/some/../../path?foo=bar`; - const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); - expect(result).toBeNull(); + if (allowExternal) { + it('allows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + + it('allows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/path?foo=bar`); + }); + + it('allows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + } else { + it('disallows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + } + }); }); describe('handles protocol resolution bypass', () => { @@ -132,22 +191,26 @@ describe('External Url Service', () => { const kibanaRoot = `${serverRoot}${serverBasePath}`; const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); - it('allows relative URLs', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `/some/path?foo=bar`; - const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + internalRequestScenarios.forEach(({ description, policy }) => { + describe(description, () => { + it('allows relative URLs', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); - expect(result).toBeInstanceOf(URL); - expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); - }); + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); - it('allows absolute URLs to Kibana', () => { - const { setup } = setupService({ location, serverBasePath, policy: [] }); - const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; - const result = setup.validateUrl(urlCandidate); + it('allows absolute URLs to Kibana', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); - expect(result).toBeInstanceOf(URL); - expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + }); }); describe('handles protocol resolution bypass', () => { @@ -193,6 +256,39 @@ describe('External Url Service', () => { expect(result).toBeNull(); }); + it('does not allow external urls with a fully restricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: false, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with an unrestricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + it('allows external urls with a matching host and protocol in the allow list', () => { const { setup } = setupService({ location, diff --git a/src/core/server/external_url/config.test.ts b/src/core/server/external_url/config.test.ts index ad62003fdca8a..eeaf3751904d4 100644 --- a/src/core/server/external_url/config.test.ts +++ b/src/core/server/external_url/config.test.ts @@ -20,6 +20,18 @@ import { config } from './config'; describe('externalUrl config', () => { + it('provides a default policy allowing all external urls', () => { + expect(config.schema.validate({})).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); + }); + it('allows an empty policy', () => { expect(config.schema.validate({ policy: [] })).toMatchInlineSnapshot(` Object { @@ -96,8 +108,8 @@ describe('externalUrl config', () => { `); }); - it('requires either a host or protocol', () => { - expect(() => + it('allows a policy without a host or protocol', () => { + expect( config.schema.validate({ policy: [ { @@ -105,9 +117,15 @@ describe('externalUrl config', () => { }, ], }) - ).toThrowErrorMatchingInlineSnapshot( - `"[policy.0]: policy must include a 'host', 'protocol', or both."` - ); + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); }); describe('protocols', () => { diff --git a/src/core/server/external_url/config.ts b/src/core/server/external_url/config.ts index 46f7ce4352b4d..4a26365a0c93d 100644 --- a/src/core/server/external_url/config.ts +++ b/src/core/server/external_url/config.ts @@ -41,20 +41,11 @@ const protocolSchema = schema.string({ }, }); -const policySchema = schema.object( - { - allow: allowSchema, - protocol: schema.maybe(protocolSchema), - host: schema.maybe(hostSchema), - }, - { - validate: ({ protocol, host }) => { - if (!host && !protocol) { - throw new Error(`policy must include a 'host', 'protocol', or both.`); - } - }, - } -); +const policySchema = schema.object({ + allow: allowSchema, + protocol: schema.maybe(protocolSchema), + host: schema.maybe(hostSchema), +}); export const config = { path: 'externalUrl', @@ -63,8 +54,6 @@ export const config = { defaultValue: [ { allow: true, - protocol: '*', - host: '*', }, ], }), diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index 98d7ea9b2b4c5..f154dc65ef26c 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -44,7 +44,7 @@ export interface IExternalUrlPolicy { /** * Optional host describing the external destination. - * May be combined with `protocol`. Required if `protocol` is not defined. + * May be combined with `protocol`. * * @example * ```ts @@ -57,7 +57,7 @@ export interface IExternalUrlPolicy { /** * Optional protocol describing the external destination. - * May be combined with `host`. Required if `host` is not defined. + * May be combined with `host`. * * @example * ```ts @@ -74,17 +74,14 @@ export interface IExternalUrlPolicy { * @public */ export class ExternalUrlConfig implements IExternalUrlConfig { - static readonly DEFAULT = new ExternalUrlConfig(); + static readonly DEFAULT = new ExternalUrlConfig(DEFAULT_CONFIG); public readonly policy: IExternalUrlPolicy[]; - /** * Returns the default External Url configuration when passed with no config * @internal */ - constructor(rawConfig: Partial = {}) { - const source = { ...DEFAULT_CONFIG, ...rawConfig }; - - this.policy = source.policy; + constructor(rawConfig: IExternalUrlConfig) { + this.policy = rawConfig.policy; } } diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 384acba24b617..1a7317ab4c00a 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -27,8 +27,6 @@ Object { "policy": Array [ Object { "allow": true, - "host": "*", - "protocol": "*", }, ], }, @@ -79,8 +77,6 @@ Object { "policy": Array [ Object { "allow": true, - "host": "*", - "protocol": "*", }, ], }, @@ -131,8 +127,6 @@ Object { "policy": Array [ Object { "allow": true, - "host": "*", - "protocol": "*", }, ], }, @@ -187,8 +181,6 @@ Object { "policy": Array [ Object { "allow": true, - "host": "*", - "protocol": "*", }, ], }, @@ -239,8 +231,6 @@ Object { "policy": Array [ Object { "allow": true, - "host": "*", - "protocol": "*", }, ], }, From c1a9b13536551f3b1b2c0e04f6c748965cac70fb Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 8 Dec 2020 09:52:16 -0500 Subject: [PATCH 4/9] Hash configured hostnames before sending to client --- .../public/http/external_url_service.test.ts | 45 ++++++++++++++++++- src/core/public/http/external_url_service.ts | 26 ++++++++--- .../external_url/external_url_config.ts | 11 ++++- src/core/server/http/http_config.ts | 2 +- src/core/server/http/http_service.ts | 8 +++- src/core/utils/crypto.test.ts | 39 ++++++++++++++++ src/core/utils/crypto.ts | 33 ++++++++++++++ src/core/utils/index.ts | 1 + 8 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/core/utils/crypto.test.ts create mode 100644 src/core/utils/crypto.ts diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index 24ce15f202f80..1385ba5c83fa0 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -18,6 +18,7 @@ */ import { ExternalUrlConfig } from 'src/core/server/types'; +import { createSHA256Hash } from '../../utils'; import { injectedMetadataServiceMock } from '../mocks'; @@ -32,8 +33,12 @@ const setupService = ({ serverBasePath: string; policy: ExternalUrlConfig['policy']; }) => { + const hashedPolicies = policy.map((entry) => ({ + ...entry, + host: entry.host ? createSHA256Hash(entry.host) : undefined, + })); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); - injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy }); + injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); const service = new ExternalUrlService(); @@ -308,6 +313,44 @@ describe('External Url Service', () => { expect(result?.toString()).toEqual(urlCandidate); }); + it('allows external urls with a partially matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls that specify a locally addressable host', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'some-host-name', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://some-host-name/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + it('disallows external urls with a matching host and unmatched protocol', () => { const { setup } = setupService({ location, diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index 71992a79e8f3e..bb1c99fb09746 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -20,19 +20,35 @@ import { IExternalUrlPolicy } from 'src/core/server/types'; import { CoreService } from 'src/core/types'; -import { InjectedMetadataSetup } from '../injected_metadata'; +import { createSHA256Hash } from '../../utils'; import { IExternalUrl } from './types'; +import { InjectedMetadataSetup } from '../injected_metadata'; interface SetupDeps { location: Pick; injectedMetadata: InjectedMetadataSetup; } -const isHostMatch = (actualHost: string, ruleHost: string) => { - const hostParts = actualHost.split('.').reverse(); - const ruleParts = ruleHost.split('.').reverse(); +function* getHostHashes(actualHost: string) { + yield createSHA256Hash(actualHost); + let host = actualHost.substr(actualHost.indexOf('.') + 1); + while (host) { + const hash = createSHA256Hash(host); + yield hash; + if (host.indexOf('.') === -1) { + break; + } + host = host.substr(host.indexOf('.') + 1); + } +} - return ruleParts.every((part, idx) => part === hostParts[idx]); +const isHostMatch = (actualHost: string, ruleHostHash: string) => { + for (const hash of getHostHashes(actualHost)) { + if (hash === ruleHostHash) { + return true; + } + } + return false; }; const isProtocolMatch = (actualProtocol: string, ruleProtocol: string) => { diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index f154dc65ef26c..11d005e0f7f04 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createSHA256Hash } from '../../utils'; import { config } from './config'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -82,6 +83,14 @@ export class ExternalUrlConfig implements IExternalUrlConfig { * @internal */ constructor(rawConfig: IExternalUrlConfig) { - this.policy = rawConfig.policy; + this.policy = rawConfig.policy.map((entry) => { + if (entry.host) { + return { + ...entry, + host: createSHA256Hash(entry.host), + }; + } + return entry; + }); } } diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 26a661ef7a090..ef34eafc41984 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -177,7 +177,7 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); - this.externalUrl = new ExternalUrlConfig(rawExternalUrlConfig); + this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; } diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 5e9d3093b092f..ae2e82d8b2241 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -44,7 +44,11 @@ import { import { RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; -import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; +import { + ExternalUrlConfigType, + config as externalUrlConfig, + ExternalUrlConfig, +} from '../external_url'; interface SetupDeps { context: ContextSetup; @@ -105,7 +109,7 @@ export class HttpService this.internalSetup = { ...serverContract, - externalUrl: config.externalUrl, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: (path: string, pluginId: PluginOpaqueId = this.coreContext.coreId) => { const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); diff --git a/src/core/utils/crypto.test.ts b/src/core/utils/crypto.test.ts new file mode 100644 index 0000000000000..11775ed53690b --- /dev/null +++ b/src/core/utils/crypto.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from './crypto'; + +describe('createSHA256Hash', () => { + it('creates a hex-encoded hash by default', () => { + expect(createSHA256Hash('foo')).toMatchInlineSnapshot( + `"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"` + ); + }); + + it('allows the output encoding to be changed', () => { + expect(createSHA256Hash('foo', 'base64')).toMatchInlineSnapshot( + `"LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="` + ); + }); + + it('accepts a buffer as input', () => { + const data = Buffer.from('foo', 'utf8'); + expect(createSHA256Hash(data)).toEqual(createSHA256Hash('foo')); + }); +}); diff --git a/src/core/utils/crypto.ts b/src/core/utils/crypto.ts new file mode 100644 index 0000000000000..de9eee2efad5a --- /dev/null +++ b/src/core/utils/crypto.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import crypto, { HexBase64Latin1Encoding } from 'crypto'; + +export const createSHA256Hash = ( + input: string | Buffer, + outputEncoding: HexBase64Latin1Encoding = 'hex' +) => { + let data: Buffer; + if (typeof input === 'string') { + data = Buffer.from(input, 'utf8'); + } else { + data = input; + } + return crypto.createHash('sha256').update(data).digest(outputEncoding); +}; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index c620e4e5ee155..04cd7fd194096 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -25,4 +25,5 @@ export { IContextContainer, IContextProvider, } from './context'; +export { createSHA256Hash } from './crypto'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; From c8b8dfeb8a10a64b3d219ed30f3abc70d3056acc Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 8 Dec 2020 10:25:49 -0500 Subject: [PATCH 5/9] fix cookie session storage tests --- .../http/cookie_session_storage.test.ts | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 7ac7e4b9712d0..0e7b55b7d35ab 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -46,29 +46,40 @@ const setupDeps = { context: contextSetup, }; -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['http://1.2.3.4'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - healthCheck: { - delay: 2000, - }, - ssl: { - verificationMode: 'none', - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['http://1.2.3.4'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + healthCheck: { + delay: 2000, + }, + ssl: { + verificationMode: 'none', + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); beforeEach(() => { logger = loggingSystemMock.create(); From 4cb05a0e8f4eebc00ebf769e050a4efe56a58ee3 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 8 Dec 2020 10:55:25 -0500 Subject: [PATCH 6/9] updating test utils --- src/core/server/http/test_utils.ts | 55 ++++++++++++++++++------------ 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index cdcbe513e1224..0a5cee5505ef1 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -32,28 +32,39 @@ const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const logger = loggingSystemMock.create(); const configService = configServiceMock.create(); -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - keepaliveTimeout: 120_000, - socketTimeout: 120_000, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + keepaliveTimeout: 120_000, + socketTimeout: 120_000, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); const defaultContext: CoreContext = { coreId, From 341d948661c6e85ce272076b880145469848150e Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 8 Dec 2020 11:48:52 -0500 Subject: [PATCH 7/9] Revert shared SHA256 function to reduce core bundle size --- src/core/public/http/external_url_service.test.ts | 4 ++-- src/core/public/http/external_url_service.ts | 7 +++---- src/core/server/external_url/external_url_config.ts | 2 +- src/core/server/utils/crypto/index.ts | 1 + .../crypto.test.ts => server/utils/crypto/sha256.test.ts} | 2 +- .../{utils/crypto.ts => server/utils/crypto/sha256.ts} | 0 src/core/utils/index.ts | 1 - 7 files changed, 8 insertions(+), 9 deletions(-) rename src/core/{utils/crypto.test.ts => server/utils/crypto/sha256.test.ts} (96%) rename src/core/{utils/crypto.ts => server/utils/crypto/sha256.ts} (100%) diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index 1385ba5c83fa0..d5371be253f86 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -18,9 +18,9 @@ */ import { ExternalUrlConfig } from 'src/core/server/types'; -import { createSHA256Hash } from '../../utils'; import { injectedMetadataServiceMock } from '../mocks'; +import { Sha256 } from '../utils'; import { ExternalUrlService } from './external_url_service'; @@ -35,7 +35,7 @@ const setupService = ({ }) => { const hashedPolicies = policy.map((entry) => ({ ...entry, - host: entry.host ? createSHA256Hash(entry.host) : undefined, + host: entry.host ? new Sha256().update(entry.host, 'utf8').digest('hex') : undefined, })); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index bb1c99fb09746..05ebe00e7de50 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -20,9 +20,9 @@ import { IExternalUrlPolicy } from 'src/core/server/types'; import { CoreService } from 'src/core/types'; -import { createSHA256Hash } from '../../utils'; import { IExternalUrl } from './types'; import { InjectedMetadataSetup } from '../injected_metadata'; +import { Sha256 } from '../utils'; interface SetupDeps { location: Pick; @@ -30,11 +30,10 @@ interface SetupDeps { } function* getHostHashes(actualHost: string) { - yield createSHA256Hash(actualHost); + yield new Sha256().update(actualHost, 'utf8').digest('hex'); let host = actualHost.substr(actualHost.indexOf('.') + 1); while (host) { - const hash = createSHA256Hash(host); - yield hash; + yield new Sha256().update(host, 'utf8').digest('hex'); if (host.indexOf('.') === -1) { break; } diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index 11d005e0f7f04..ad2a7940ec029 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -17,7 +17,7 @@ * under the License. */ -import { createSHA256Hash } from '../../utils'; +import { createSHA256Hash } from '../utils'; import { config } from './config'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); diff --git a/src/core/server/utils/crypto/index.ts b/src/core/server/utils/crypto/index.ts index 9a36682cc4ecb..aa9728e0462d6 100644 --- a/src/core/server/utils/crypto/index.ts +++ b/src/core/server/utils/crypto/index.ts @@ -18,3 +18,4 @@ */ export { Pkcs12ReadResult, readPkcs12Keystore, readPkcs12Truststore } from './pkcs12'; +export { createSHA256Hash } from './sha256'; diff --git a/src/core/utils/crypto.test.ts b/src/core/server/utils/crypto/sha256.test.ts similarity index 96% rename from src/core/utils/crypto.test.ts rename to src/core/server/utils/crypto/sha256.test.ts index 11775ed53690b..ddb8ffee36da6 100644 --- a/src/core/utils/crypto.test.ts +++ b/src/core/server/utils/crypto/sha256.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { createSHA256Hash } from './crypto'; +import { createSHA256Hash } from './sha256'; describe('createSHA256Hash', () => { it('creates a hex-encoded hash by default', () => { diff --git a/src/core/utils/crypto.ts b/src/core/server/utils/crypto/sha256.ts similarity index 100% rename from src/core/utils/crypto.ts rename to src/core/server/utils/crypto/sha256.ts diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 04cd7fd194096..c620e4e5ee155 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -25,5 +25,4 @@ export { IContextContainer, IContextProvider, } from './context'; -export { createSHA256Hash } from './crypto'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; From f3dd0ba037b38f99f42c523178f20dca2bc52820 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 8 Dec 2020 11:52:43 -0500 Subject: [PATCH 8/9] Updating test setup for lifecycle_handlers --- .../lifecycle_handlers.test.ts | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index 7df35b04c66cf..ba7f55caeba22 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -50,26 +50,37 @@ describe('core lifecycle handlers', () => { beforeEach(async () => { const configService = configServiceMock.create(); - configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - name: kibanaName, - customResponseHeaders: { - 'some-header': 'some-value', - }, - xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) - ); + configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + name: kibanaName, + customResponseHeaders: { + 'some-header': 'some-value', + }, + 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({} as any); + } + throw new Error(`Unexpected config path: ${path}`); + }); server = createHttpServer({ configService }); const serverSetup = await server.setup(setupDeps); From 85c1dfb72c907422b8b1105f2977ab4a77192dfa Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Dec 2020 14:45:38 -0500 Subject: [PATCH 9/9] properly handle hosts ending in a 'dot', and account for IPv6 hosts --- .../public/http/external_url_service.test.ts | 72 +++++++++++++++++-- src/core/public/http/external_url_service.ts | 5 +- .../external_url/external_url_config.ts | 7 +- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index d5371be253f86..af34dba5e6216 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -33,10 +33,17 @@ const setupService = ({ serverBasePath: string; policy: ExternalUrlConfig['policy']; }) => { - const hashedPolicies = policy.map((entry) => ({ - ...entry, - host: entry.host ? new Sha256().update(entry.host, 'utf8').digest('hex') : undefined, - })); + const hashedPolicies = policy.map((entry) => { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; + return { + ...entry, + host: hostToHash ? new Sha256().update(hostToHash, 'utf8').digest('hex') : undefined, + }; + }); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); @@ -332,6 +339,63 @@ describe('External Url Service', () => { expect(result?.toString()).toEqual(urlCandidate); }); + it('allows external urls with a partially matching host and protocol in the allow list when the URL includes the root domain', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com./foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv4 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '192.168.10.12', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://192.168.10.12/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv6 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + it('allows external urls that specify a locally addressable host', () => { const { setup } = setupService({ location, diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index 05ebe00e7de50..e975451a7fdaa 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -42,7 +42,10 @@ function* getHostHashes(actualHost: string) { } const isHostMatch = (actualHost: string, ruleHostHash: string) => { - for (const hash of getHostHashes(actualHost)) { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + !actualHost.includes('[') && !actualHost.endsWith('.') ? `${actualHost}.` : actualHost; + for (const hash of getHostHashes(hostToHash)) { if (hash === ruleHostHash) { return true; } diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index ad2a7940ec029..065a9cd1d2609 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -85,9 +85,14 @@ export class ExternalUrlConfig implements IExternalUrlConfig { constructor(rawConfig: IExternalUrlConfig) { this.policy = rawConfig.policy.map((entry) => { if (entry.host) { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; return { ...entry, - host: createSHA256Hash(entry.host), + host: createSHA256Hash(hostToHash), }; } return entry;